refactor: 각 어댑터터별 리펙토링 진행

This commit is contained in:
DongHyeonka
2026-08-24 18:26:40 +09:00
parent e98b56eb03
commit 0137263441
439 changed files with 31935 additions and 4719 deletions
@@ -1,6 +1,6 @@
// Redis SDK leaf — see docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md.
//
// The design models the SDK as separate Gradle modules. This repository's fail-closed 19-leaf
// The design models the SDK as separate Gradle modules. This repository's fail-closed module
// registry outranks that layout, so the module boundaries are packages under
// dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them.
dependencies {
+57 -89
View File
@@ -3,7 +3,7 @@
// docs/httpclient/repository-adaptation.md (how the design's 19 library modules map here).
//
// The design models the platform as 19 separate Gradle modules. This repository's fail-closed
// 19-leaf registry (src/config/architecture/modules.json) outranks that layout, so the module
// module registry (src/config/architecture/modules.json) outranks that layout, so the module
// boundaries are packages under dev.caskeleton.adapter.outbound.httpclient and
// HttpClientModuleBoundaryTest enforces the design's module dependency table.
description = 'Outbound adapter: HTTP client platform (typed clients, profiles, evidence-based retry)'
@@ -73,33 +73,12 @@ dependencies {
// Performance certification and JMH benchmarks are separate source sets for their own reason: they
// are slow, they assert on resource bounds rather than behaviour, and they must never be part of
// the default unit lane.
sourceSets {
testkit {
java.srcDir 'src/testkit/java'
compileClasspath += sourceSets.main.output
runtimeClasspath += output + compileClasspath
}
httpClientPerformanceTest {
java.srcDir 'src/httpClientPerformanceTest/java'
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
runtimeClasspath += output + compileClasspath
}
jmh {
java.srcDir 'src/jmh/java'
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
runtimeClasspath += output + compileClasspath
}
}
configurations {
// The testkit compiles against exactly what a test does: testImplementation already extends
// implementation, so this is the module's own dependencies plus the test libraries.
testkitImplementation.extendsFrom testImplementation
testkitRuntimeOnly.extendsFrom testRuntimeOnly
httpClientPerformanceTestImplementation.extendsFrom testImplementation
httpClientPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly
jmhImplementation.extendsFrom testImplementation
jmhRuntimeOnly.extendsFrom testRuntimeOnly
strictTestLanes {
// The testkit compiles against exactly what a test does: `implementation` inheritance runs
// through testImplementation, so this is the module's own dependencies plus the test libraries.
sourceSet('testkit') { compilesAgainst 'main' }
sourceSet('httpClientPerformanceTest') { compilesAgainst 'main', 'testkit' }
sourceSet('jmh') { compilesAgainst 'main', 'testkit' }
}
// Every test lane compiles and runs against the testkit.
@@ -166,69 +145,58 @@ tasks.named('test', Test) {
}
}
tasks.register('httpClientBlockHoundTest', Test) {
group = 'verification'
description = 'Proves no platform code blocks a Reactor event loop (design §18.2, §28.6).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-blockhound' }
applyContractSelection(it)
// BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them.
jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods'
// The lane exists to run BlockHound. Discovering nothing means it did not, which is a failure.
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
// Four tag-selected lanes, declared rather than assembled.
//
// Two of them — the stable contract suite and the security suite — did not carry
// failOnNoDiscoveredTests at all. Five lanes were written by copying the block above, and the
// property that makes a lane mean anything was lost on two of the copies, so the cross-transport
// contract suite and the SSRF/credential-leak suite would each have reported success on discovering
// nothing. Declaring the lanes removes the opportunity: the convention has no opt-out.
strictTestLanes {
lane('httpClientBlockHoundTest') {
tag = 'httpclient-blockhound'
description = 'Proves no platform code blocks a Reactor event loop (design §18.2, §28.6).'
customize = { test ->
applyContractSelection(test)
// BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them.
test.jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods'
}
}
lane('httpClientStableContractTest') {
tag = 'httpclient-contract'
description = 'Runs the cross-transport stable contract suite (design §28.2, §33).'
customize = { test -> applyContractSelection(test) }
}
lane('httpClientSecurityTest') {
tag = 'httpclient-security'
description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).'
customize = { test -> applyContractSelection(test) }
}
// Its own source set rather than a tag, so the source set is the selection.
lane('httpClientPerformanceTest') {
sourceSet = 'httpClientPerformanceTest'
description = 'Certifies pool, streaming, retry, and rotation resource bounds (design §28.8).'
customize = { test ->
applyContractSelection(test)
test.systemProperty 'performance.assertions.enabled',
(project.findProperty('performance.assertions.enabled') ?: 'false').toString()
}
}
lane('httpClientFailureInjectionTest') {
tag = 'httpclient-fault'
description = 'Runs the Toxiproxy fault-injection suite; fails closed without Docker ' +
'(design §28.3).'
customize = { test ->
applyContractSelection(test)
// The upstream image is mutable by default. Passing a digest here is what makes a red
// fault run attributable to this repository rather than to someone else's image push.
test.systemProperty 'httpclient.fault.httpbin.image',
(project.findProperty('httpclient.fault.httpbin.image')
?: 'kennethreitz/httpbin:latest').toString()
}
}
}
tasks.register('httpClientStableContractTest', Test) {
group = 'verification'
description = 'Runs the cross-transport stable contract suite (design §28.2, §33).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-contract' }
applyContractSelection(it)
outputs.upToDateWhen { false }
}
tasks.register('httpClientSecurityTest', Test) {
group = 'verification'
description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-security' }
applyContractSelection(it)
outputs.upToDateWhen { false }
}
tasks.register('httpClientFailureInjectionTest', Test) {
group = 'verification'
description = 'Runs the Toxiproxy fault-injection suite; fails closed without Docker (design §28.3).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-fault' }
applyContractSelection(it)
// The upstream image is mutable by default. Passing a digest here is what makes a red fault
// run attributable to this repository rather than to someone else's image push.
systemProperty 'httpclient.fault.httpbin.image',
(project.findProperty('httpclient.fault.httpbin.image') ?: 'kennethreitz/httpbin:latest').toString()
// A fault suite that never injected a fault must not report success, so a selected lane with no
// discovered test is an error rather than an empty pass.
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('httpClientPerformanceTest', Test) {
group = 'verification'
description = 'Certifies pool, streaming, retry, and rotation resource bounds (design §28.8).'
testClassesDirs = sourceSets.httpClientPerformanceTest.output.classesDirs
classpath = sourceSets.httpClientPerformanceTest.runtimeClasspath
useJUnitPlatform()
applyContractSelection(it)
systemProperty 'performance.assertions.enabled',
(project.findProperty('performance.assertions.enabled') ?: 'false').toString()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('jmh', JavaExec) {
group = 'verification'
@@ -1,4 +1,3 @@
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
dependencies {
implementation project(':application-core')
+17 -2
View File
@@ -20,8 +20,13 @@ dependencies {
implementation 'io.projectreactor:reactor-core'
// JSON Schema 2020-12 validation of template variables, using the same validator and version the
// messaging adapter already depends on rather than a second implementation of the same spec.
// The YAML dataformat is excluded: schemas are supplied as JSON strings, so pulling a YAML
// parser onto the runtime classpath would add attack surface for a format nothing reads.
//
// Jackson's YAML dataformat is excluded because schemas arrive as JSON strings and a second
// parser for a format this leaf never reads is surface for nothing. It does not remove YAML from
// the runtime — org.yaml:snakeyaml is on this classpath via spring-boot-starter, which is how
// Spring Boot reads application.yml. The comment here used to claim the stronger outcome, and
// the resolved graph had said otherwise for as long as it stood; dependencyPolicy below now
// states the claim the build can check.
// Thymeleaf is the reference HTML renderer, added as the engine only — not the Spring
// starter, which would drag a view resolver and a servlet integration onto an outbound
// adapter that renders strings and never serves a request.
@@ -37,3 +42,13 @@ dependencies {
testImplementation 'io.projectreactor:reactor-test'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
// The exclusion above, stated as something the build verifies rather than something a comment
// asserts. verifyDependencyPolicy resolves runtimeClasspath and fails if the coordinate is present.
dependencyPolicy {
absent 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml',
because: 'schemas arrive as JSON strings; a second YAML parser is surface for a format ' +
'this leaf never reads'
absent 'tools.jackson.dataformat:jackson-dataformat-yaml',
because: 'the Jackson 3 coordinate of the same parser, excluded for the same reason'
}
@@ -86,13 +86,19 @@ public record NotificationPlatformSettings(
/**
* The largest body the platform can retain, derived rather than chosen.
*
* <p>It was one mebibyte, while the ciphertext column holds 65,536 bytes and encryption adds a
* 12-byte nonce and a 16-byte tag. Three layers each enforced a different number: configuration
* allowed a mebibyte, the MVC controller hard-coded 65,536, and the database rejected anything
* over 65,536 *after* encryption — so a body of exactly the configured maximum passed every
* check above the database and failed the CHECK constraint, having already been acknowledged.
* <p>It was one mebibyte, while the ciphertext column holds 65,536 bytes and the envelope adds
* a version byte, a key id, a nonce and a tag. Three layers each enforced a different number:
* configuration allowed a mebibyte, the MVC controller hard-coded 65,536, and the database
* rejected anything over 65,536 *after* encryption — so a body of exactly the configured
* maximum passed every check above the database and failed the CHECK constraint, having already
* been acknowledged.
*
* <p>Read from the protector rather than restated, because a number written twice is a number
* that drifts the first time the envelope gains a field.
*/
private static final long MAX_BODY_CEILING = 65_536L - 28L;
private static final long MAX_BODY_CEILING =
dev.caskeleton.adapter.outbound.notification.platform.security
.AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES;
public Callbacks {
Objects.requireNonNull(replaySkew, "replaySkew");
@@ -100,7 +106,12 @@ public record NotificationPlatformSettings(
throw new IllegalArgumentException(
"max-body-bytes must be 1.."
+ MAX_BODY_CEILING
+ "; the ciphertext column holds 65536 bytes and encryption adds 28");
+ "; the ciphertext column holds "
+ dev.caskeleton.adapter.outbound.notification.platform.security
.AesGcmCallbackPayloadProtection.MAX_CIPHERTEXT_BYTES
+ " bytes and the envelope adds "
+ dev.caskeleton.adapter.outbound.notification.platform.security
.AesGcmCallbackPayloadProtection.ENVELOPE_OVERHEAD_BYTES);
}
if (replaySkew.isNegative()) {
throw new IllegalArgumentException("replay-skew must not be negative");
@@ -109,8 +120,8 @@ public record NotificationPlatformSettings(
/** Conservative defaults. */
public static Callbacks defaults() {
// The storable maximum, not the column size: encryption adds 28 bytes, so a default of
// 65,536 was a default that could not be stored.
// The storable maximum, not the column size: the envelope adds a version byte, a key id, a
// nonce and a tag, so a default of 65,536 was a default that could not be stored.
return new Callbacks(false, MAX_BODY_CEILING, Duration.ofMinutes(5));
}
}
@@ -0,0 +1,98 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.util.EnumSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Which key purposes a given configuration actually needs.
*
* <p>Startup demanded all eight, always. That is fail-closed in the wrong direction: it made every
* deployment provision and rotate keys for capabilities it had switched off — a Web Push signing
* key for a platform with no Web Push profile, a callback signing key for a platform with no
* callback endpoint — and a key that exists but is never used is a key nobody notices leaking. It
* also made the eight look equally load-bearing, so nothing distinguished the four that every mode
* needs from the four that follow a capability.
*
* <p>The direction that must not weaken is the other one: a capability that is switched <em>on</em>
* and whose key is missing still refuses the boot, because the alternative is discovering it on a
* user's notification. This class decides only what is required; validation of whatever is supplied
* happens regardless, so an unused key that is configured is still checked rather than trusted.
*/
public final class NotificationSecretRequirements {
private NotificationSecretRequirements() {}
/**
* The purposes this configuration must supply.
*
* @param settings the bound platform configuration
* @return the required purposes, never empty
*/
public static Set<SecretPurpose> requiredBy(NotificationPlatformSettings settings) {
Objects.requireNonNull(settings, "settings");
Set<SecretPurpose> required = EnumSet.copyOf(ALWAYS);
if (settings.callbacks().enabled()) {
// The callback endpoint verifies a provider signature and fingerprints the payload for
// deduplication. Both happen on the first callback that arrives, so neither key can be
// deferred to "when it is needed".
required.add(SecretPurpose.CALLBACK_SIGNING);
required.add(SecretPurpose.CALLBACK_FINGERPRINT_HMAC);
}
for (Map.Entry<String, NotificationPlatformSettings.Provider> entry :
settings.providers().entrySet()) {
NotificationPlatformSettings.Provider profile = entry.getValue();
if (!profile.enabled()) {
continue;
}
ProviderType type = ProviderType.parse(entry.getKey(), profile.type());
if (authenticatesWithAPlatformCredential(type)) {
required.add(SecretPurpose.PROVIDER_CREDENTIAL);
}
if (type == ProviderType.WEB_PUSH) {
required.add(SecretPurpose.VAPID_SIGNING);
}
if (profile.callbackSigningSecretRef() != null
&& !profile.callbackSigningSecretRef().isBlank()) {
// A profile that names a signing key ref intends to verify or produce signatures whatever
// the platform-wide callback switch says.
required.add(SecretPurpose.CALLBACK_SIGNING);
}
}
return Set.copyOf(required);
}
/**
* Whether a family authenticates to its provider with a key this platform holds.
*
* <p>SMTP does not: its relay address, user and password come from Spring's own {@code
* spring.mail.*} through the injected mail sender, which is why {@code
* SmtpProviderRuntimeAssembler} never touches the secret store. Demanding a provider credential
* for an SMTP-only deployment asked an operator to invent a secret with nothing to authenticate
* to.
*/
private static boolean authenticatesWithAPlatformCredential(ProviderType type) {
return type != ProviderType.SMTP;
}
/**
* The purposes every mode needs, including {@code INGEST_ONLY}.
*
* <p>Each of these is on the accept path rather than the dispatch path, so switching every
* provider off does not switch any of them off. Contact points are encrypted and their lookup
* hashes computed when a recipient is resolved; notification variables are encrypted at rest by
* the record mapper, which takes the protection as a constructor argument with no fallback; and
* provider request ids are hashed by the attempt store and the event ledger on every row they
* write.
*/
private static final Set<SecretPurpose> ALWAYS =
Set.of(
SecretPurpose.CONTACT_ENCRYPTION,
SecretPurpose.CONTACT_LOOKUP_HMAC,
SecretPurpose.PAYLOAD_ENCRYPTION,
SecretPurpose.PROVIDER_REQUEST_LOOKUP_HMAC);
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider;
import dev.caskeleton.application.notification.platform.api.TenantId;
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
import dev.caskeleton.application.notification.platform.provider.AttachmentAccessContext;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Opens the attachments an email submission declares.
*
* <p>Every email provider needs the same three things in the same order — resolve, verify, close —
* and each of them is a silent failure when a second copy gets one wrong: an unresolved attachment
* becomes a mail missing the document it is about, an unverified one becomes bytes nobody approved,
* and an unclosed one becomes a leaked stream that only shows up under load.
*
* <p>The integrity check runs before the provider call, not after. A digest or size that does not
* match what the caller pinned at submit time means these are not the approved bytes, and finding
* that out once the mail has left is finding it out too late.
*/
public final class EmailAttachments {
private EmailAttachments() {}
/**
* Resolves and verifies everything the content declares.
*
* <p>The caller closes the result on every path, including the failing ones. Content that is not
* email, or email that declares nothing, resolves to an empty list rather than to a failure —
* having no attachment is the normal case.
*/
public static List<ResolvedAttachment> open(
AttachmentIntegrityGuard guard, ProviderSubmission submission) {
Objects.requireNonNull(guard, "guard");
Objects.requireNonNull(submission, "submission");
if (!(submission.content().content() instanceof EmailContent email)
|| email.attachments().isEmpty()) {
return List.of();
}
AttachmentAccessContext context =
new AttachmentAccessContext(
new TenantId(submission.profile().environment()), submission.attemptId());
List<ResolvedAttachment> resolved = new ArrayList<>(email.attachments().size());
try {
for (var reference : email.attachments()) {
resolved.add(guard.resolve(reference, context));
}
return List.copyOf(resolved);
} catch (RuntimeException failure) {
// Everything already opened is closed before the failure propagates. Half a resolution is
// still half a set of open streams.
closeAll(resolved);
throw failure;
}
}
/** Closes everything that was opened, whatever the send did. */
public static void closeAll(List<ResolvedAttachment> attachments) {
Objects.requireNonNull(attachments, "attachments");
for (ResolvedAttachment attachment : attachments) {
try {
attachment.close();
} catch (Exception ignored) {
// A stream that will not close is not a reason to change the send's outcome, and a failed
// send is exactly when a leaked one would otherwise go unnoticed.
}
}
}
}
@@ -1,24 +1,28 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.provider.EmailAttachments;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
import dev.caskeleton.application.notification.platform.security.AccessContext;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
@@ -42,25 +46,28 @@ public final class SesNotificationProviderAdapter implements NotificationProvide
private final NotificationHttpGateway gateway;
private final SesRequestMapper mapper;
private final AttachmentIntegrityGuard attachmentGuard;
private final SesFailureClassifier classifier;
private final ContactPointProtector protector;
private final SecretMaterialProvider secrets;
private final ProviderCredentialManager credentials;
private final String accessKeyId;
private final Clock clock;
public SesNotificationProviderAdapter(
NotificationHttpGateway gateway,
SesRequestMapper mapper,
AttachmentIntegrityGuard attachmentGuard,
SesFailureClassifier classifier,
ContactPointProtector protector,
SecretMaterialProvider secrets,
ProviderCredentialManager credentials,
String accessKeyId,
Clock clock) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.mapper = Objects.requireNonNull(mapper, "mapper");
this.attachmentGuard = Objects.requireNonNull(attachmentGuard, "attachmentGuard");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.credentials = Objects.requireNonNull(credentials, "credentials");
this.accessKeyId = Objects.requireNonNull(accessKeyId, "accessKeyId");
this.clock = Objects.requireNonNull(clock, "clock");
}
@@ -77,8 +84,21 @@ public final class SesNotificationProviderAdapter implements NotificationProvide
@Override
public ProviderCapabilities capabilities() {
// The payload ceiling is the mapper's own constant rather than a second copy of the number:
// the runtime plans against what is declared here and the mapper refuses against what it holds,
// and two spellings of the same limit is one of them being wrong.
return new ProviderCapabilities(
false, false, true, false, false, false, false, false, 1, 10_000_000L, Duration.ofDays(1));
false,
false,
true,
false,
false,
false,
false,
false,
1,
SesRequestMapper.MAX_MESSAGE_BYTES,
Duration.ofDays(1));
}
@Override
@@ -97,13 +117,28 @@ public final class SesNotificationProviderAdapter implements NotificationProvide
throw new IllegalArgumentException("SES requires an email contact point");
}
var request =
mapper.map(
submission,
address.normalized(),
accessKeyId,
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(),
clock.instant());
// Resolved and verified before the request is shaped, and closed as soon as it is. An
// attachment only reaches the wire through the raw MIME message the mapper builds from these
// streams, so they have to be open for exactly that long and no longer.
List<ResolvedAttachment> opened = EmailAttachments.open(attachmentGuard, submission);
NotificationHttpRequest request;
try {
request =
mapper.map(
submission,
address.normalized(),
opened,
accessKeyId,
// This profile's credential at the generation the submission was planned against, not
// the platform's one current provider credential. Sharing a single key across every
// profile made one leaked SES account's key a leak of every provider account, and
// made a per-profile rotation inexpressible.
credentials.materialFor(
submission.profile().profileId(), submission.profile().credentialGeneration()),
clock.instant());
} finally {
EmailAttachments.closeAll(opened);
}
try {
NotificationHttpResponse response = gateway.exchange(request);
@@ -20,7 +20,11 @@ public record SesProviderProperties(
Objects.requireNonNull(senderIdentity, "senderIdentity");
Objects.requireNonNull(configurationSet, "configurationSet");
Objects.requireNonNull(timeout, "timeout");
NotificationEndpoints.requireSecureOrLoopback(endpoint, "SES endpoint");
// See WebhookSubscription for why this is the stronger guard now. An SES endpoint is operator
// configured rather than user supplied, so loopback stays available for local and contract
// profiles; what is refused is a configured endpoint that resolves into the deployment's own
// network.
NotificationEndpoints.requireExternallyRoutable(endpoint, "SES endpoint", true);
if (region.isBlank() || senderIdentity.isBlank()) {
throw new IllegalArgumentException("region and senderIdentity must not be blank");
}
@@ -2,54 +2,98 @@ package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.NotificationValidationException;
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
import jakarta.mail.MessagingException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** Builds the signed SES v2 send request. */
/**
* Builds the signed SES v2 send request.
*
* <p>Two content shapes, chosen by what the notification actually carries. {@code Simple} is a
* subject and two bodies and nothing else, so an email that declares an attachment is assembled as
* a MIME message and sent as {@code Raw} content instead. Content that declared an attachment used
* to be sent as {@code Simple} regardless: the document was silently absent from the mail SES sent
* and the attempt was still recorded as delivered, which is a recipient told to read something that
* is not there.
*
* <p>The MIME message is built by the factory the SMTP provider already uses, rather than by a
* second assembly of the same rendered email. Multipart layout, UTF-8 and the header-separator
* rejection that keeps a subject from turning a notification into someone else's mail are decided
* once; two builders would be two places for those answers to drift apart.
*
* <p>What the raw path still cannot express is refused before the request is signed, so nothing has
* been sent when it happens: content that is not email, an attachment the resolver did not hand
* back, and a message larger than SES will accept.
*/
public final class SesRequestMapper {
/**
* The largest message SES accepts, measured on the bytes that go on the wire.
*
* <p>Measured after assembly rather than against the declared attachment sizes, because base64
* transfer encoding adds a third: a set of parts that clears the limit before encoding and
* exceeds it after would be rejected by SES with the attempt already made, and an attempt made is
* an attempt the evidence model has to reason about.
*/
public static final long MAX_MESSAGE_BYTES = 10_000_000L;
private static final String PATH = "/v2/email/outbound-emails";
private final SesProviderProperties properties;
private final AwsSignatureV4Signer signer;
private final SmtpMimeMessageFactory mimeFactory;
public SesRequestMapper(SesProviderProperties properties, AwsSignatureV4Signer signer) {
public SesRequestMapper(
SesProviderProperties properties,
AwsSignatureV4Signer signer,
SmtpMimeMessageFactory mimeFactory) {
this.properties = Objects.requireNonNull(properties, "properties");
this.signer = Objects.requireNonNull(signer, "signer");
this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory");
}
/** Map one submission into a signed request. */
/**
* Map one submission into a signed request.
*
* @param attachments the already resolved and verified attachments, open for the length of this
* call; the caller closes them
*/
public NotificationHttpRequest map(
ProviderSubmission submission,
String recipientAddress,
List<ResolvedAttachment> attachments,
String accessKeyId,
byte[] secretAccessKey,
Instant signedAt) {
Objects.requireNonNull(submission, "submission");
Objects.requireNonNull(recipientAddress, "recipientAddress");
Objects.requireNonNull(attachments, "attachments");
if (!(submission.content().content() instanceof EmailContent email)) {
throw new IllegalArgumentException("SES requires email content");
}
Map<String, Object> simple = new LinkedHashMap<>();
simple.put("Subject", Map.of("Data", email.subject(), "Charset", "UTF-8"));
Map<String, Object> bodyParts = new LinkedHashMap<>();
bodyParts.put("Text", Map.of("Data", email.textBody(), "Charset", "UTF-8"));
email
.htmlBody()
.ifPresent(html -> bodyParts.put("Html", Map.of("Data", html, "Charset", "UTF-8")));
simple.put("Body", bodyParts);
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("FromEmailAddress", properties.senderIdentity());
payload.put("Destination", Map.of("ToAddresses", java.util.List.of(recipientAddress)));
payload.put("Content", Map.of("Simple", simple));
payload.put("Destination", Map.of("ToAddresses", List.of(recipientAddress)));
payload.put("Content", content(submission, email, recipientAddress, attachments));
properties.configurationSet().ifPresent(name -> payload.put("ConfigurationSetName", name));
byte[] body =
@@ -83,4 +127,83 @@ public final class SesRequestMapper {
body,
properties.timeout());
}
/**
* The content shape this email needs.
*
* <p>The decision is made from what the content <em>declares</em>, not from what was handed in:
* an email that declares an attachment and arrives with fewer than it declared must not fall back
* to {@code Simple}, because that is precisely the send that leaves the document behind and
* reports success.
*/
private Map<String, Object> content(
ProviderSubmission submission,
EmailContent email,
String recipientAddress,
List<ResolvedAttachment> attachments) {
if (email.attachments().isEmpty()) {
return Map.of("Simple", simple(email));
}
if (attachments.size() != email.attachments().size()) {
throw rejection(
new IllegalStateException(
"the submission declares "
+ email.attachments().size()
+ " attachments and "
+ attachments.size()
+ " were opened"));
}
return Map.of("Raw", Map.of("Data", rawMessage(submission, recipientAddress, attachments)));
}
/** The MIME message, base64 encoded as the SES v2 JSON binding requires for a blob. */
private String rawMessage(
ProviderSubmission submission,
String recipientAddress,
List<ResolvedAttachment> attachments) {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try {
mimeFactory
.create(submission, recipientAddress, properties.senderIdentity(), attachments)
.writeTo(buffer);
} catch (IOException | MessagingException failure) {
// The cause, never the content: which step of assembly failed is what an operator needs, and
// the bytes it failed on are the recipient's document.
throw rejection(failure);
}
byte[] message = buffer.toByteArray();
if (message.length > MAX_MESSAGE_BYTES) {
throw new ProviderPayloadLimitException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD));
}
return Base64.getEncoder().encodeToString(message);
}
private static Map<String, Object> simple(EmailContent email) {
Map<String, Object> simple = new LinkedHashMap<>();
simple.put("Subject", Map.of("Data", email.subject(), "Charset", "UTF-8"));
Map<String, Object> bodyParts = new LinkedHashMap<>();
bodyParts.put("Text", Map.of("Data", email.textBody(), "Charset", "UTF-8"));
email
.htmlBody()
.ifPresent(html -> bodyParts.put("Html", Map.of("Data", html, "Charset", "UTF-8")));
simple.put("Body", bodyParts);
return simple;
}
/**
* The refusal, carrying what caused it.
*
* <p>One descriptor for every shaping failure — it is what ends up on the delivery row, and a
* per-check code there is a metric-cardinality problem. The cause is what tells an operator which
* check fired.
*/
private static NotificationValidationException rejection(Throwable cause) {
return new NotificationValidationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD),
cause);
}
}
@@ -10,6 +10,8 @@ import dev.caskeleton.application.notification.platform.provider.ResolvedAttachm
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.internet.MimeMessage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
@@ -72,8 +74,16 @@ public final class SmtpMimeMessageFactory {
helper.setText(email.textBody(), false);
}
for (ResolvedAttachment attachment : attachments) {
byte[] bytes = read(attachment);
// A source that can be read again, not the resolver's one-shot stream. JavaMail reads an
// attachment twice — once to choose the part's transfer encoding, once to write the part —
// and the second read of an already drained stream returned nothing. The part that went out
// announced a filename and carried no bytes, so the mail arrived with an empty attachment
// and the attempt was still recorded as accepted.
helper.addAttachment(
attachment.displayName(), () -> attachment.content(), attachment.contentType());
attachment.displayName(),
() -> new ByteArrayInputStream(bytes),
attachment.contentType());
}
for (var header : email.options().approvedHeaders().entrySet()) {
requireHeaderSafe(header.getKey(), "approved header name");
@@ -86,6 +96,30 @@ public final class SmtpMimeMessageFactory {
}
}
/**
* Reads the attachment into memory once.
*
* <p>Bounded by the size the integrity guard already pinned against the reference, and the read
* is checked against it: a stream that turns out to be longer or shorter than the size that was
* verified is not the content that was approved, whatever its reported digest said.
*/
private static byte[] read(ResolvedAttachment attachment) {
byte[] bytes;
try {
bytes = attachment.content().readAllBytes();
} catch (IOException unreadable) {
throw rejection(unreadable);
}
if (bytes.length != attachment.size()) {
// The count, never the bytes: how much was read is diagnostic, what was read is the
// recipient's document.
throw rejection(
new IllegalStateException(
"attachment declared " + attachment.size() + " bytes and read " + bytes.length));
}
return bytes;
}
private static void requireHeaderSafe(String value, String field) {
if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\0') >= 0) {
// The field name, never the value: a header-injection attempt is exactly the payload that
@@ -1,10 +1,11 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import dev.caskeleton.adapter.outbound.notification.platform.provider.EmailAttachments;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
@@ -38,8 +39,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
private final ContactPointProtector protector;
private final SmtpProviderProperties properties;
private final Executor executor;
private final dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard
attachmentGuard;
private final AttachmentIntegrityGuard attachmentGuard;
public SmtpNotificationProviderAdapter(
SmtpDispatch dispatch,
@@ -48,8 +48,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
ContactPointProtector protector,
SmtpProviderProperties properties,
Executor executor,
dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard
attachmentGuard) {
AttachmentIntegrityGuard attachmentGuard) {
this.dispatch = Objects.requireNonNull(dispatch, "dispatch");
this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory");
this.classifier = Objects.requireNonNull(classifier, "classifier");
@@ -96,7 +95,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
// Resolved, verified and closed around the send. The factory was handed List.of() whatever the
// content asked for, so an email with attachments went out without them — the caller was told
// it was accepted, and the recipient received a message missing the thing it was about.
List<ResolvedAttachment> opened = resolve(submission);
List<ResolvedAttachment> opened = EmailAttachments.open(attachmentGuard, submission);
try {
dispatch.send(
mimeFactory.create(
@@ -105,51 +104,7 @@ public final class SmtpNotificationProviderAdapter implements NotificationProvid
} catch (SmtpDispatchException failure) {
return classifier.classify(failure, elapsedSince(startedNanos));
} finally {
// Closed on every path. A resolver hands back an open stream, and a failed send is exactly
// when a leaked one goes unnoticed.
opened.forEach(SmtpNotificationProviderAdapter::closeQuietly);
}
}
/**
* Resolves and verifies every attachment the content declares.
*
* <p>The integrity guard runs before the provider call, not after: a digest or size that does not
* match what the caller declared means the bytes are not the bytes that were approved, and
* discovering that after the mail has left is discovering it too late.
*/
private List<ResolvedAttachment> resolve(ProviderSubmission submission) {
if (!(submission.content().content() instanceof EmailContent email)
|| email.attachments().isEmpty()) {
return List.of();
}
List<ResolvedAttachment> resolved = new java.util.ArrayList<>(email.attachments().size());
try {
for (var reference : email.attachments()) {
// The guard resolves and verifies size and digest in one step, so an attachment whose
// bytes are not the approved bytes never reaches the MIME factory.
resolved.add(
attachmentGuard.resolve(
reference,
new dev.caskeleton.application.notification.platform.provider
.AttachmentAccessContext(
new dev.caskeleton.application.notification.platform.api.TenantId(
submission.profile().environment()),
submission.attemptId())));
}
return List.copyOf(resolved);
} catch (RuntimeException failure) {
// Everything already opened is closed before the failure propagates.
resolved.forEach(SmtpNotificationProviderAdapter::closeQuietly);
throw failure;
}
}
private static void closeQuietly(ResolvedAttachment attachment) {
try {
attachment.close();
} catch (Exception ignored) {
// A stream that will not close is not a reason to change the send's outcome.
EmailAttachments.closeAll(opened);
}
}
@@ -52,7 +52,7 @@ public final class TwilioCallbackAdapter implements ProviderCallbackAdapter {
properties.canonicalCallbackUrl(),
parameters,
request.header("x-twilio-signature").orElse(null),
secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material());
signingKey());
return valid
? CallbackVerificationResult.valid(new VerifiedCallback(request, parameters))
: CallbackVerificationResult.invalid("TWILIO_SIGNATURE_MISMATCH");
@@ -65,6 +65,26 @@ public final class TwilioCallbackAdapter implements ProviderCallbackAdapter {
return List.of(normalizer.normalize(callback.canonicalParameters(), occurredAt));
}
/**
* The key this profile's callbacks are signed with.
*
* <p>Verification used the platform's one current callback signing key, so every Twilio profile
* in a deployment shared it: a subaccount whose token leaked could forge status callbacks for any
* other, and a profile could not be rotated on its own. {@code callbackSigningKeyRef} is the
* profile's own reference, and a reference naming a key issued for another purpose is a
* configuration fault rather than a signature that quietly never matches.
*/
private byte[] signingKey() {
var key = secrets.keyById(properties.callbackSigningKeyRef());
if (key.purpose() != SecretPurpose.CALLBACK_SIGNING) {
throw new IllegalStateException(
"twilio profile for account "
+ properties.accountSid()
+ " names a key that is not a callback signing key");
}
return key.material();
}
private static Map<String, String> parseForm(byte[] body) {
Map<String, String> parameters = new LinkedHashMap<>();
String raw = new String(body, StandardCharsets.UTF_8);
@@ -12,6 +12,10 @@ import java.util.Optional;
* request. Twilio signs the URL it called, and a reverse proxy that rewrites scheme or host makes a
* server-side reconstruction disagree with the signature — the most common cause of "valid webhook,
* failed verification".
*
* <p>{@code callbackSigningKeyRef} is this profile's own signing key, mirroring the profile's
* {@code callback-signing-secret-ref} setting. Verification used the platform's single current
* callback signing key, so every profile shared one secret.
*/
public record TwilioProviderProperties(
URI endpoint,
@@ -19,6 +23,7 @@ public record TwilioProviderProperties(
Optional<String> messagingServiceSid,
Optional<String> fromNumber,
String canonicalCallbackUrl,
String callbackSigningKeyRef,
Duration timeout,
Duration maxReconciliationAge) {
@@ -28,11 +33,17 @@ public record TwilioProviderProperties(
Objects.requireNonNull(messagingServiceSid, "messagingServiceSid");
Objects.requireNonNull(fromNumber, "fromNumber");
Objects.requireNonNull(canonicalCallbackUrl, "canonicalCallbackUrl");
Objects.requireNonNull(callbackSigningKeyRef, "callbackSigningKeyRef");
Objects.requireNonNull(timeout, "timeout");
Objects.requireNonNull(maxReconciliationAge, "maxReconciliationAge");
if (accountSid.isBlank()) {
throw new IllegalArgumentException("accountSid");
}
if (callbackSigningKeyRef.isBlank()) {
// Blank would fall back to whatever key is current, which is the platform-wide sharing this
// reference exists to end.
throw new IllegalArgumentException("callbackSigningKeyRef");
}
if (messagingServiceSid.isEmpty() == fromNumber.isEmpty()) {
throw new IllegalArgumentException(
"exactly one of messagingServiceSid or fromNumber must be configured");
@@ -5,13 +5,12 @@ import dev.caskeleton.adapter.outbound.notification.platform.provider.http.Notif
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability;
import dev.caskeleton.application.notification.platform.provider.ReconciliationResult;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
@@ -37,19 +36,19 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
private final NotificationHttpGateway gateway;
private final TwilioProviderProperties properties;
private final TwilioStatusNormalizer normalizer;
private final SecretMaterialProvider secrets;
private final ProviderCredentialManager credentials;
private final Clock clock;
public TwilioReconciliationCapability(
NotificationHttpGateway gateway,
TwilioProviderProperties properties,
TwilioStatusNormalizer normalizer,
SecretMaterialProvider secrets,
ProviderCredentialManager credentials,
Clock clock) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.properties = Objects.requireNonNull(properties, "properties");
this.normalizer = Objects.requireNonNull(normalizer, "normalizer");
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.credentials = Objects.requireNonNull(credentials, "credentials");
this.clock = Objects.requireNonNull(clock, "clock");
}
@@ -75,7 +74,8 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
}
try {
NotificationHttpResponse response = gateway.exchange(statusRequest(messageSid.get()));
NotificationHttpResponse response =
gateway.exchange(statusRequest(attempt, messageSid.get()));
if (!response.isSuccessful()) {
return CompletableFuture.completedFuture(
new ReconciliationResult.Failed(
@@ -111,14 +111,19 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
};
}
private NotificationHttpRequest statusRequest(String messageSid) {
String credentials =
private NotificationHttpRequest statusRequest(
DeliveryAttemptSnapshot attempt, String messageSid) {
// The credential the attempt was made with, not whichever one is current. A status query is a
// question about work that already happened, and asking it with a newer generation's token
// fails once a rotation has landed — precisely when reconciliation matters most.
String authorization =
Base64.getEncoder()
.encodeToString(
(properties.accountSid()
+ ":"
+ new String(
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(),
credentials.materialFor(
attempt.providerProfileId(), attempt.credentialGeneration()),
StandardCharsets.UTF_8))
.getBytes(StandardCharsets.UTF_8));
@@ -131,7 +136,7 @@ public final class TwilioReconciliationCapability implements ReconciliationCapab
+ "/Messages/"
+ messageSid
+ ".json"),
JdkNotificationHttpGateway.headers(Map.of("authorization", "Basic " + credentials)),
JdkNotificationHttpGateway.headers(Map.of("authorization", "Basic " + authorization)),
new byte[0],
properties.timeout());
}
@@ -4,6 +4,7 @@ import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderRe
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
@@ -15,8 +16,6 @@ import dev.caskeleton.application.notification.platform.provider.ProviderSubmiss
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import dev.caskeleton.application.notification.platform.security.AccessContext;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
@@ -40,19 +39,19 @@ public final class TwilioSmsProviderAdapter implements NotificationProviderAdapt
private final TwilioRequestMapper mapper;
private final TwilioFailureClassifier classifier;
private final ContactPointProtector protector;
private final SecretMaterialProvider secrets;
private final ProviderCredentialManager credentials;
public TwilioSmsProviderAdapter(
NotificationHttpGateway gateway,
TwilioRequestMapper mapper,
TwilioFailureClassifier classifier,
ContactPointProtector protector,
SecretMaterialProvider secrets) {
ProviderCredentialManager credentials) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.mapper = Objects.requireNonNull(mapper, "mapper");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.credentials = Objects.requireNonNull(credentials, "credentials");
}
@Override
@@ -93,7 +92,11 @@ public final class TwilioSmsProviderAdapter implements NotificationProviderAdapt
mapper.map(
submission,
phone.e164(),
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material());
// This profile's auth token at the generation the submission was planned against. One
// platform-wide provider credential meant a leak of one Twilio account's token was a
// leak of every profile's, whichever provider they belonged to.
credentials.materialFor(
submission.profile().profileId(), submission.profile().credentialGeneration()));
try {
NotificationHttpResponse response = gateway.exchange(request);
@@ -10,6 +10,8 @@ import dev.caskeleton.adapter.outbound.notification.platform.template.Notificati
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
@@ -82,10 +84,23 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
return Set.of(Channel.WEBHOOK);
}
/** The largest body this adapter will put on the wire. */
public static final long MAX_BODY_BYTES = 1_000_000L;
@Override
public ProviderCapabilities capabilities() {
return new ProviderCapabilities(
false, false, false, false, false, false, false, false, 1, 1_000_000L, Duration.ofHours(1));
false,
false,
false,
false,
false,
false,
false,
false,
1,
MAX_BODY_BYTES,
Duration.ofHours(1));
}
@Override
@@ -130,6 +145,14 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
NotificationJsonMapper.mapper()
.writeValueAsString(envelope)
.getBytes(StandardCharsets.UTF_8);
// Measured on the bytes that will be sent. The capability declared a ceiling and nothing
// enforced it, so an oversized body was discovered by the receiver rejecting it — after the
// request had been made, which for a webhook is after the receiver may already have acted.
if (body.length > MAX_BODY_BYTES) {
throw new ProviderPayloadLimitException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD));
}
Map<String, String> headers = new LinkedHashMap<>();
headers.put("content-type", "application/json");
@@ -139,8 +162,7 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
WebhookSignatureStrategy.TIMESTAMP_HEADER, Long.toString(timestamp.getEpochSecond()));
headers.put(
WebhookSignatureStrategy.SIGNATURE_HEADER,
signatures.sign(
body, timestamp, secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material()));
signatures.sign(body, timestamp, signingKey(subscription)));
}
NotificationHttpRequest request =
@@ -182,6 +204,29 @@ public final class WebhookNotificationProviderAdapter implements NotificationPro
}
}
/**
* The key a subscription's signature is computed with.
*
* <p>{@code signingKeyRef} was read only to decide whether to sign at all, and the signature was
* then computed with the platform's current callback signing key. Every trusted subscription
* therefore shared one secret: a receiver holding its own key could verify — and forge —
* deliveries meant for any other, and rotating one subscription's key rotated all of them.
*
* @throws IllegalStateException if the reference names a key that is not a callback signing key,
* which is a configuration fault and is raised before the request is made rather than
* producing a signature the receiver will reject
*/
private byte[] signingKey(WebhookSubscription subscription) {
var key = secrets.keyById(subscription.signingKeyRef().orElseThrow());
if (key.purpose() != SecretPurpose.CALLBACK_SIGNING) {
throw new IllegalStateException(
"webhook subscription "
+ subscription.subscriptionId()
+ " names a key that is not a callback signing key");
}
return key.material();
}
/**
* The receiver's own backoff hint, when it sent a usable one.
*
@@ -11,6 +11,10 @@ import java.util.Optional;
* <p>{@code trusted} decides which gateway carries the call. A trusted subscription is operator
* configured and may use platform credentials; a dynamic one comes from user input and must not
* inherit anything, because that is how a webhook feature becomes an SSRF credential-relay.
*
* <p>It decides the loopback allowance for the same reason. An operator naming a local endpoint is
* describing their own deployment; a client naming one is asking the platform to deliver a message
* body to an interface the client cannot otherwise reach.
*/
public record WebhookSubscription(
String subscriptionId, URI target, boolean trusted, Optional<String> signingKeyRef) {
@@ -22,7 +26,27 @@ public record WebhookSubscription(
if (subscriptionId.isBlank()) {
throw new IllegalArgumentException("subscriptionId");
}
NotificationEndpoints.requireSecureOrLoopback(target, "webhook target");
// requireExternallyRoutable, not requireSecureOrLoopback. The scheme check accepted any HTTPS
// URL, so `https://169.254.169.254/` — the cloud metadata service — and every RFC 1918 address
// passed. The stronger guard was written for exactly this call site and then called from
// nowhere: it existed, its own tests were green, and the two sites it was written for kept the
// weaker check.
//
// The loopback allowance is `trusted`, not a constant. It was `true` for every caller, which
// left one case open: a client-supplied target naming `localhost` reached the loopback
// interface. Closing it was deferred on the grounds that the allowance had to become a decision
// the caller states and no caller existed to state it — but the decision is this record's first
// boolean, and two lines below it already decides whether the target may inherit a platform
// signing key. A subscription an operator configured may address a local endpoint, because the
// operator profiles and the contract harness do exactly that. One that came from user input may
// not, for the same reason it may not inherit credentials: it is not the deployment's own
// address to name.
//
// What remains open is narrower and belongs to the guard, not here: the target is resolved once
// at construction and re-resolved independently by the HTTP client, so a name that changes its
// answer between the two is refused only if the first lookup already shows an internal address.
// NTF-012, docs/reviews/2026-08-14-notification-module-code-review.md.
NotificationEndpoints.requireExternallyRoutable(target, "webhook target", trusted);
if (!trusted && signingKeyRef.isPresent()) {
throw new IllegalArgumentException(
"a dynamic target may not be paired with a platform signing key");
@@ -4,18 +4,16 @@ import dev.caskeleton.adapter.outbound.notification.platform.template.Notificati
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.callback.CallbackPayloadProtectionPort;
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
import dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.Objects;
import java.util.TreeMap;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
@@ -25,35 +23,36 @@ import javax.crypto.spec.SecretKeySpec;
* provider actually sent — but it routinely contains addresses and message metadata, so it is
* encrypted and truncated rather than stored as received.
*
* <p>The nonce is prefixed to the ciphertext so a rotation does not need a second column, and the
* fingerprint is keyed so that providers without an event id still get collision-resistant,
* <p>The stored bytes are the same versioned, key-identified envelope the notification payload
* column uses, and for the same reason. This class used to write a bare nonce and ciphertext: the
* day the payload encryption key rotated, every retained callback became unreadable and nothing in
* the row could say which key it had needed. A retention format whose whole justification is later
* diagnosis has to survive the rotation that happens in between.
*
* <p>The fingerprint is keyed so that providers without an event id still get collision-resistant,
* non-enumerable duplicate detection.
*/
public final class AesGcmCallbackPayloadProtection implements CallbackPayloadProtectionPort {
private static final int NONCE_BYTES = 12;
private static final int TAG_BITS = 128;
private final SecretMaterialProvider secrets;
private final SecureRandom random;
private final NotificationPayloadProtection payloads;
private final int maxRetainedBytes;
public AesGcmCallbackPayloadProtection(SecretMaterialProvider secrets, int maxRetainedBytes) {
this(secrets, new SecureRandom(), maxRetainedBytes);
}
AesGcmCallbackPayloadProtection(
SecretMaterialProvider secrets, SecureRandom random, int maxRetainedBytes) {
public AesGcmCallbackPayloadProtection(
SecretMaterialProvider secrets,
NotificationPayloadProtection payloads,
int maxRetainedBytes) {
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.random = Objects.requireNonNull(random, "random");
this.payloads = Objects.requireNonNull(payloads, "payloads");
if (maxRetainedBytes < 1) {
throw new IllegalArgumentException("maxRetainedBytes");
}
if (maxRetainedBytes > MAX_PLAINTEXT_BYTES) {
// The database check constrains the *ciphertext*, and encryption adds a 12-byte nonce and a
// 16-byte GCM tag. Truncating the plaintext to the ciphertext bound produced a value 28 bytes
// over it, so a callback of exactly the configured maximum was accepted by every layer above
// and then rejected by a CHECK constraint after the provider had been told it was stored.
// The database check constrains the *ciphertext*, and encryption adds a version byte, a key
// id, a nonce and a GCM tag. Truncating the plaintext to the ciphertext bound produced a
// value larger than it, so a callback of exactly the configured maximum was accepted by
// every layer above and then rejected by a CHECK constraint after the provider had been told
// it was stored.
throw new IllegalArgumentException(
"callback retention of "
+ maxRetainedBytes
@@ -75,8 +74,15 @@ public final class AesGcmCallbackPayloadProtection implements CallbackPayloadPro
*/
public static final int MAX_CIPHERTEXT_BYTES = 65_536;
/** The nonce and GCM tag every encryption adds. */
public static final int ENVELOPE_OVERHEAD_BYTES = NONCE_BYTES + TAG_BITS / 8;
/**
* The most the envelope adds: version, key id, nonce and GCM tag.
*
* <p>Reserved at the largest key id the envelope allows rather than measured against the current
* one, because a rotation to a longer id would otherwise push a body that fit yesterday past the
* column's check constraint.
*/
public static final int ENVELOPE_OVERHEAD_BYTES =
AesGcmNotificationPayloadProtection.MAX_ENVELOPE_OVERHEAD_BYTES;
/** The largest plaintext that still fits the column once encrypted. */
public static final int MAX_PLAINTEXT_BYTES = MAX_CIPHERTEXT_BYTES - ENVELOPE_OVERHEAD_BYTES;
@@ -86,22 +92,10 @@ public final class AesGcmCallbackPayloadProtection implements CallbackPayloadPro
Objects.requireNonNull(rawBody, "rawBody");
byte[] bounded =
rawBody.length <= maxRetainedBytes ? rawBody : Arrays.copyOf(rawBody, maxRetainedBytes);
byte[] nonce = new byte[NONCE_BYTES];
random.nextBytes(nonce);
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
Cipher.ENCRYPT_MODE,
new SecretKeySpec(secrets.activeKey(SecretPurpose.PAYLOAD_ENCRYPTION).material(), "AES"),
new GCMParameterSpec(TAG_BITS, nonce));
byte[] ciphertext = cipher.doFinal(bounded);
byte[] stored = new byte[nonce.length + ciphertext.length];
System.arraycopy(nonce, 0, stored, 0, nonce.length);
System.arraycopy(ciphertext, 0, stored, nonce.length, ciphertext.length);
return stored;
} catch (GeneralSecurityException failure) {
throw new IllegalStateException("callback payload encryption failed", failure);
}
// Delegated rather than reimplemented so the retained callback and the retained notification
// payload are one format with one reader. The alternative is two envelopes that drift, and the
// one that drifts is always the one nothing reads until an incident.
return payloads.protect(bounded);
}
@Override
@@ -50,6 +50,16 @@ public final class AesGcmNotificationPayloadProtection implements NotificationPa
private static final int TAG_BITS = 128;
private static final int MAX_KEY_ID_BYTES = 255;
/**
* The most this envelope can add to a plaintext.
*
* <p>The header is variable — a key id is one to 255 bytes — so anything that has to guarantee a
* ciphertext fits a fixed column reserves the largest header rather than the current one. A bound
* computed from today's key id stops holding the moment a rotation picks a longer one.
*/
static final int MAX_ENVELOPE_OVERHEAD_BYTES =
2 + MAX_KEY_ID_BYTES + NONCE_BYTES + TAG_BITS / Byte.SIZE;
private final SecretMaterialProvider secrets;
private final SecureRandom random;
@@ -5,6 +5,7 @@ import dev.caskeleton.application.notification.platform.security.SecretKeyMateri
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
@@ -14,6 +15,15 @@ import java.util.concurrent.ConcurrentHashMap;
/**
* Tracks which credential generation is current for each provider profile.
*
* <p>A rotation is a window, not an instant. Activating a new generation supersedes the previous
* one but keeps it resolvable for a bounded drain period, because work planned against the old
* generation is already in flight when the rotation lands: an attempt whose request may already
* have reached the provider cannot simply be failed, and cannot be replayed either. Callers ask for
* the generation their work was planned with — {@code ProviderProfileSnapshot} and {@code
* DeliveryAttemptSnapshot} both carry it — so the window covers exactly that work and nothing else.
* Past the window the old credential stops resolving, because a superseded credential that stays
* usable indefinitely is not a rotation, it is two live credentials.
*
* <p>Two rotations are deliberately <em>not</em> handled here, because treating them as ordinary
* credential swaps would silently lose data or delivery:
*
@@ -31,13 +41,46 @@ public final class ProviderCredentialManager {
private final SecretMaterialProvider secrets;
private final Clock clock;
private final Duration drainWindow;
private final Map<ProviderProfileId, CredentialGeneration> current = new ConcurrentHashMap<>();
private final Map<ProviderProfileId, Map<Long, Draining>> draining = new ConcurrentHashMap<>();
public ProviderCredentialManager(SecretMaterialProvider secrets, Clock clock) {
/**
* Creates the manager.
*
* @param secrets the key store
* @param clock the clock the drain window is measured against
* @param drainWindow how long a superseded generation keeps serving the work that started on it
*/
public ProviderCredentialManager(
SecretMaterialProvider secrets, Clock clock, Duration drainWindow) {
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.clock = Objects.requireNonNull(clock, "clock");
Objects.requireNonNull(drainWindow, "drainWindow");
if (drainWindow.isNegative()) {
throw new IllegalArgumentException("drainWindow");
}
this.drainWindow = drainWindow;
}
/**
* The default drain window: long enough for an in-flight attempt, short enough to be a window.
*/
public static final Duration DEFAULT_DRAIN_WINDOW = Duration.ofMinutes(15);
/**
* Creates the manager with the default drain window.
*
* @param secrets the key store
* @param clock the clock the drain window is measured against
*/
public ProviderCredentialManager(SecretMaterialProvider secrets, Clock clock) {
this(secrets, clock, DEFAULT_DRAIN_WINDOW);
}
/** A superseded generation and the instant it stops being usable. */
private record Draining(CredentialGeneration generation, Instant usableUntil) {}
/**
* Record the generation a profile starts on.
*
@@ -63,10 +106,76 @@ public final class ProviderCredentialManager {
if (existing != null && !generation.supersedes(existing)) {
throw new IllegalArgumentException("generation does not supersede the active one");
}
if (existing != null) {
// Superseded, not deleted. An attempt that was planned against the previous generation
// is already in flight when the rotation lands, and retiring the credential the instant
// the new one arrives fails exactly that work — the requests nobody can replay, because
// the provider may already have acted on them. The window bounds it: an old credential
// that stays usable forever is not a rotation, it is two live credentials.
retire(profileId, existing, activatedAt);
}
return generation.activatedAt(activatedAt);
});
}
private void retire(
ProviderProfileId profileId, CredentialGeneration superseded, Instant supersededAt) {
draining
.computeIfAbsent(profileId, id -> new ConcurrentHashMap<>())
.put(superseded.generation(), new Draining(superseded, supersededAt.plus(drainWindow)));
}
/**
* Credential material for one profile at the generation the work was planned against.
*
* <p>Every adapter resolved {@code activeKey(PROVIDER_CREDENTIAL)} instead: one credential for
* every profile in the deployment, so a leak of one provider account's key was a leak of all of
* them, and a per-profile rotation was not expressible at all. The generation is not a parameter
* a caller invents — {@code ProviderProfileSnapshot.credentialGeneration()} and {@code
* DeliveryAttemptSnapshot.credentialGeneration()} already carry the number the work was planned
* with, which is what makes the drain window mean something rather than being a grace period
* nobody claims.
*
* @param profileId the profile the work belongs to
* @param generation the generation the work was planned against
* @return the material
* @throws IllegalStateException if the profile has no active generation, or the requested one is
* neither current nor still inside its drain window
*/
public byte[] materialFor(ProviderProfileId profileId, long generation) {
Objects.requireNonNull(profileId, "profileId");
CredentialGeneration active = current.get(profileId);
if (active == null) {
throw new IllegalStateException(
"provider profile " + profileId.value() + " has no activated credential generation");
}
if (active.generation() == generation) {
return material(active).material();
}
Draining retired = draining.getOrDefault(profileId, Map.of()).get(generation);
if (retired == null) {
throw new IllegalStateException(
"provider profile "
+ profileId.value()
+ " has no credential generation "
+ generation
+ "; the active generation is "
+ active.generation());
}
if (!clock.instant().isBefore(retired.usableUntil())) {
// Dropped rather than served: past the window, work still asking for the old generation is
// work that has been stuck long enough that using a retired credential is the larger risk.
draining.getOrDefault(profileId, Map.of()).remove(generation);
throw new IllegalStateException(
"credential generation "
+ generation
+ " for provider profile "
+ profileId.value()
+ " finished draining; it is no longer usable");
}
return material(retired.generation()).material();
}
/** Current generation of a profile. */
public Optional<CredentialGeneration> current(ProviderProfileId profileId) {
return Optional.ofNullable(current.get(Objects.requireNonNull(profileId, "profileId")));
@@ -10,7 +10,6 @@ import java.util.Map;
* renderer per engine is how two implementations end up computing different digests for the same
* template, which silently breaks the retry equality the digest exists to prove.
*/
@FunctionalInterface
public interface NotificationTemplateEngine {
/**
@@ -27,12 +26,17 @@ public interface NotificationTemplateEngine {
* <p>The mode is required rather than inferred: the same template text is safe in a text part and
* dangerous in an HTML one, and only the caller knows which it is filling.
*
* <p>Abstract, not a {@code default} that forwards to the single-argument overload. It was that
* default, and one of the two engines never overrode it — so selecting that engine silently
* dropped every slot to the unescaped path: a subject could carry CR/LF, a deep link could carry
* a {@code javascript:} scheme, and plain text had its ampersands HTML-escaped on the wire. A
* default that discards its own argument is not a fallback; it is the rule not applying, and the
* engine that skipped it looked complete because the interface compiled.
*
* @param mode what the rendered value will become
* @param source the template text
* @param variables the values to substitute
* @return the rendered slot
*/
default String render(TemplateSlotMode mode, String source, Map<String, Object> variables) {
return render(source, variables);
}
String render(TemplateSlotMode mode, String source, Map<String, Object> variables);
}
@@ -61,79 +61,8 @@ public final class PlaceholderTemplateEngine implements NotificationTemplateEngi
/** Escapes one substituted value for its destination. */
private static String escape(TemplateSlotMode mode, String value) {
return switch (mode) {
case TEXT -> value;
case SUBJECT -> requireSingleLine(value);
case HTML_TEXT -> escapeHtml(value);
case URI -> requireAllowedScheme(value);
};
// The rules moved to TemplateSlotPolicy so the other engine could reach them. They were private
// here, which is why that engine had none.
return TemplateSlotPolicy.escape(mode, value);
}
/**
* Refuses a value that would split a header.
*
* <p>A carriage return or newline in a subject is header injection: everything after it is read
* as a new header by the receiving agent.
*/
private static String requireSingleLine(String value) {
for (int index = 0; index < value.length(); index++) {
if (value.charAt(index) < 0x20) {
throw new TemplateRenderingException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.TEMPLATE_RENDERING_FAILED,
FailureCategory.TEMPLATE_FAILURE));
}
}
return value;
}
/**
* Escapes for HTML text and attribute content.
*
* <p>Quotes included, because a value substituted inside an attribute can otherwise close it and
* start an event handler — {@code " onerror="} needs no angle bracket at all.
*/
private static String escapeHtml(String value) {
StringBuilder escaped = new StringBuilder(value.length() + 16);
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
switch (character) {
case '&' -> escaped.append("&amp;");
case '<' -> escaped.append("&lt;");
case '>' -> escaped.append("&gt;");
case '"' -> escaped.append("&quot;");
case '\'' -> escaped.append("&#x27;");
default -> escaped.append(character);
}
}
return escaped.toString();
}
/**
* Allows only schemes a notification may legitimately link to.
*
* <p>{@code javascript:} in a link is script execution; {@code data:} is an arbitrary document
* the platform vouches for; {@code file:} points at the reader's own machine. The slot used to be
* parsed as a URI and otherwise accepted, and parsing succeeds for all three.
*/
private static String requireAllowedScheme(String value) {
String normalized = value.trim().toLowerCase(java.util.Locale.ROOT);
boolean allowed =
ALLOWED_URI_SCHEMES.stream().anyMatch(scheme -> normalized.startsWith(scheme + ":"));
if (!allowed) {
throw new TemplateRenderingException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.TEMPLATE_RENDERING_FAILED, FailureCategory.TEMPLATE_FAILURE));
}
return value;
}
/**
* The schemes a rendered link may use.
*
* <p>HTTPS, and the application's own deep-link scheme. Plain HTTP is absent deliberately: a link
* in a notification is followed by a person who has no way to check it.
*/
private static final java.util.Set<String> ALLOWED_URI_SCHEMES =
java.util.Set.of("https", "caskeleton");
}
@@ -0,0 +1,129 @@
package dev.caskeleton.adapter.outbound.notification.platform.template;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException;
import java.util.Locale;
import java.util.Set;
/**
* What each slot mode means, in one place both engines use.
*
* <p>These rules lived as private helpers inside {@code PlaceholderTemplateEngine}, and the other
* engine had no equivalent — it did not override the mode-aware render at all, so selecting it
* dropped every slot to the unescaped path. One engine enforced the policy and the other did not
* have access to it.
*
* <p>Nothing here is engine-specific: a subject may not carry a control character whoever produced
* it, and a deep link may not use {@code javascript:} whoever rendered it.
*/
public final class TemplateSlotPolicy {
/**
* The schemes a rendered link may use.
*
* <p>HTTPS, and the application's own deep-link scheme. Plain HTTP is absent deliberately: a link
* in a notification is followed by a person who has no way to check it.
*/
private static final Set<String> ALLOWED_URI_SCHEMES = Set.of("https", "caskeleton");
private TemplateSlotPolicy() {}
/**
* Escapes one substituted value for its destination.
*
* @param mode the slot being filled
* @param value the value to escape
* @return the escaped value
*/
public static String escape(TemplateSlotMode mode, String value) {
return switch (mode) {
case TEXT -> value;
case SUBJECT -> requireSingleLine(value);
case HTML_TEXT -> escapeHtml(value);
case URI -> requireAllowedScheme(value);
};
}
/**
* Checks a whole rendered slot, for an engine that substitutes internally.
*
* <p>An engine that does its own substitution cannot escape per value, so the guarantee is
* applied to what it produced. For SUBJECT and URI that is the stronger statement: no control
* character anywhere in the subject, and the finished link uses an allowed scheme. Escaping modes
* are the engine's own job — asking it to render HTML and then escaping the result would escape
* the operator's markup too.
*
* @param mode the slot that was filled
* @param rendered the engine's output
* @return the output, unchanged when it satisfies the slot
*/
public static String verifyRendered(TemplateSlotMode mode, String rendered) {
return switch (mode) {
case TEXT, HTML_TEXT -> rendered;
case SUBJECT -> requireSingleLine(rendered);
case URI -> requireAllowedScheme(rendered);
};
}
/**
* Refuses a value that would split a header.
*
* <p>A carriage return or newline in a subject is header injection: everything after it is read
* as a new header by the receiving agent.
*/
private static String requireSingleLine(String value) {
for (int index = 0; index < value.length(); index++) {
if (value.charAt(index) < 0x20) {
throw refuse();
}
}
return value;
}
/**
* Escapes for HTML text and attribute content.
*
* <p>Quotes included, because a value substituted inside an attribute can otherwise close it and
* start an event handler — {@code " onerror="} needs no angle bracket at all.
*/
private static String escapeHtml(String value) {
StringBuilder escaped = new StringBuilder(value.length() + 16);
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
switch (character) {
case '&' -> escaped.append("&amp;");
case '<' -> escaped.append("&lt;");
case '>' -> escaped.append("&gt;");
case '"' -> escaped.append("&quot;");
case '\'' -> escaped.append("&#x27;");
default -> escaped.append(character);
}
}
return escaped.toString();
}
/**
* Allows only schemes a notification may legitimately link to.
*
* <p>{@code javascript:} in a link is script execution; {@code data:} is an arbitrary document
* the platform vouches for; {@code file:} points at the reader's own machine. The slot used to be
* parsed as a URI and otherwise accepted, and parsing succeeds for all three.
*/
private static String requireAllowedScheme(String value) {
String normalized = value.trim().toLowerCase(Locale.ROOT);
boolean allowed =
ALLOWED_URI_SCHEMES.stream().anyMatch(scheme -> normalized.startsWith(scheme + ":"));
if (!allowed) {
throw refuse();
}
return value;
}
private static TemplateRenderingException refuse() {
return new TemplateRenderingException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.TEMPLATE_RENDERING_FAILED, FailureCategory.TEMPLATE_FAILURE));
}
}
@@ -44,6 +44,12 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
private final TemplateEngine engine;
/** Whether the constructor-supplied engine is the HTML one. */
private final boolean htmlMode;
/** The text-mode engine, for every slot that is not HTML. */
private final TemplateEngine textEngine = engineFor(TemplateMode.TEXT);
/** HTML-escaping engine, which is the safe default for email bodies. */
public ThymeleafStringTemplateEngine() {
this(TemplateMode.HTML);
@@ -54,12 +60,25 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
*/
public ThymeleafStringTemplateEngine(TemplateMode mode) {
Objects.requireNonNull(mode, "mode");
StringTemplateResolver resolver = new StringTemplateResolver();
resolver.setTemplateMode(mode);
resolver.setCacheable(false);
TemplateEngine created = new TemplateEngine();
created.setTemplateResolver(resolver);
this.engine = created;
this.htmlMode = mode == TemplateMode.HTML;
this.engine = engineFor(mode);
}
@Override
public String render(TemplateSlotMode mode, String source, Map<String, Object> variables) {
Objects.requireNonNull(mode, "mode");
// One engine per Thymeleaf template mode, chosen by what the slot is.
//
// This class used to implement only the mode-less overload and inherit a `default` that threw
// the mode away, so every slot rendered under TemplateMode.HTML: a subject could carry CR/LF, a
// deep link could carry `javascript:`, and plain text — an SMS body — had its `&` turned into
// `&amp;` on the wire. The interface compiled, so nothing said the policy was not applying.
//
// HTML_TEXT keeps the HTML engine, which is what escapes substituted values. Everything else
// renders as text and is then checked: Thymeleaf substitutes internally, so a per-value escape
// is not available, and verifying the finished slot is the stronger statement anyway.
String rendered = engineFor(mode).process(source, contextFor(source, variables));
return TemplateSlotPolicy.verifyRendered(mode, rendered);
}
@Override
@@ -68,10 +87,8 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
Objects.requireNonNull(variables, "variables");
requireEveryReferencedVariable(source, variables);
Context context = new Context();
variables.forEach(context::setVariable);
try {
return engine.process(source, context);
return engine.process(source, contextFor(source, variables));
} catch (RuntimeException failure) {
// The message is dropped on purpose. Thymeleaf reports the offending expression, and a
// template expression contains the variable it failed on — which for this platform is a
@@ -105,4 +122,41 @@ public final class ThymeleafStringTemplateEngine implements NotificationTemplate
}
}
}
/** The engine whose template mode matches the slot. */
private TemplateEngine engineFor(TemplateSlotMode mode) {
return mode == TemplateSlotMode.HTML_TEXT ? htmlEngine() : textEngine;
}
/**
* The HTML engine.
*
* <p>The constructor-supplied engine when this instance was built for HTML, and a dedicated one
* otherwise — a deployment that constructed the text engine still has HTML slots to render, and
* rendering them as text would emit an operator's markup unescaped.
*/
private TemplateEngine htmlEngine() {
return htmlMode ? engine : HTML_ENGINE;
}
private static final TemplateEngine HTML_ENGINE = engineFor(TemplateMode.HTML);
private static TemplateEngine engineFor(TemplateMode mode) {
StringTemplateResolver resolver = new StringTemplateResolver();
resolver.setTemplateMode(mode);
resolver.setCacheable(false);
TemplateEngine created = new TemplateEngine();
created.setTemplateResolver(resolver);
return created;
}
/** Builds the variable context, refusing an absent variable rather than rendering it away. */
private Context contextFor(String source, Map<String, Object> variables) {
Objects.requireNonNull(source, "source");
Objects.requireNonNull(variables, "variables");
requireEveryReferencedVariable(source, variables);
Context context = new Context();
variables.forEach(context::setVariable);
return context;
}
}
@@ -0,0 +1,173 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.time.Duration;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Which keys a deployment is actually asked for.
*
* <p>Startup demanded all of them, always, so an SMTP-only platform with callbacks switched off had
* to provision and rotate a Web Push signing key, a provider credential and two callback keys that
* nothing in that configuration could reach. Keys that exist and are never used are the ones nobody
* notices leaking, and requiring them made the four purposes every mode genuinely needs
* indistinguishable from the four that follow a capability.
*/
class NotificationSecretRequirementsTest {
@Test
@DisplayName("the accept path's keys are required in every configuration")
void theAcceptPathKeysAreAlwaysRequired() {
var required = NotificationSecretRequirements.requiredBy(settings(false, Map.of()));
assertThat(required)
.as(
"contact points are protected, variables are encrypted at rest and provider request "
+ "ids are hashed whenever the platform runs, providers or no providers")
.containsExactlyInAnyOrder(
SecretPurpose.CONTACT_ENCRYPTION,
SecretPurpose.CONTACT_LOOKUP_HMAC,
SecretPurpose.PAYLOAD_ENCRYPTION,
SecretPurpose.PROVIDER_REQUEST_LOOKUP_HMAC);
}
@Test
@DisplayName("an SMTP-only platform is not asked for a provider credential")
void anSmtpOnlyPlatformIsNotAskedForAProviderCredential() {
var required =
NotificationSecretRequirements.requiredBy(settings(false, Map.of("mail", smtp())));
assertThat(required)
.as("SMTP authenticates through spring.mail.*, so this platform holds no SMTP credential")
.doesNotContain(SecretPurpose.PROVIDER_CREDENTIAL);
}
@Test
@DisplayName("a disabled profile does not demand its family's keys")
void aDisabledProfileDoesNotDemandItsKeys() {
var required =
NotificationSecretRequirements.requiredBy(
settings(false, Map.of("push", disabled(webPush()))));
assertThat(required)
.doesNotContain(SecretPurpose.VAPID_SIGNING)
.doesNotContain(SecretPurpose.PROVIDER_CREDENTIAL);
}
@Test
@DisplayName("an enabled Web Push profile demands a VAPID key and a provider credential")
void anEnabledWebPushProfileDemandsItsKeys() {
var required =
NotificationSecretRequirements.requiredBy(settings(false, Map.of("push", webPush())));
assertThat(required).contains(SecretPurpose.VAPID_SIGNING, SecretPurpose.PROVIDER_CREDENTIAL);
}
@Test
@DisplayName("callbacks switched off do not demand the callback keys")
void callbacksOffDoNotDemandTheCallbackKeys() {
var required =
NotificationSecretRequirements.requiredBy(settings(false, Map.of("mail", smtp())));
assertThat(required)
.doesNotContain(SecretPurpose.CALLBACK_SIGNING)
.doesNotContain(SecretPurpose.CALLBACK_FINGERPRINT_HMAC);
}
@Test
@DisplayName("callbacks switched on demand both callback keys")
void callbacksOnDemandBothCallbackKeys() {
var required =
NotificationSecretRequirements.requiredBy(settings(true, Map.of("mail", smtp())));
assertThat(required)
.as("verification and dedupe both run on the first callback that arrives")
.contains(SecretPurpose.CALLBACK_SIGNING, SecretPurpose.CALLBACK_FINGERPRINT_HMAC);
}
@Test
@DisplayName("a profile naming its own signing key ref demands the signing purpose")
void aProfileNamingASigningRefDemandsTheSigningPurpose() {
var required =
NotificationSecretRequirements.requiredBy(settings(false, Map.of("sms", twilio())));
assertThat(required)
.as("a profile that names a signing key intends to verify signatures with it")
.contains(SecretPurpose.CALLBACK_SIGNING);
}
private static NotificationPlatformSettings settings(
boolean callbacksEnabled, Map<String, NotificationPlatformSettings.Provider> providers) {
return new NotificationPlatformSettings(
true,
NotificationPlatformMode.SERVING,
null,
new NotificationPlatformSettings.Callbacks(callbacksEnabled, 1024, Duration.ofMinutes(5)),
providers);
}
private static NotificationPlatformSettings.Provider smtp() {
return new NotificationPlatformSettings.Provider(
"SMTP",
true,
true,
"PRODUCTION",
"smtp-main",
null,
null,
null,
Duration.ofSeconds(3),
8,
20);
}
private static NotificationPlatformSettings.Provider webPush() {
return new NotificationPlatformSettings.Provider(
"WEB_PUSH",
true,
true,
"PRODUCTION",
"webpush-main",
null,
"BPublicKey",
null,
Duration.ofSeconds(3),
8,
20);
}
private static NotificationPlatformSettings.Provider twilio() {
return new NotificationPlatformSettings.Provider(
"TWILIO",
true,
true,
"PRODUCTION",
"twilio-main",
null,
null,
"twilio-callback-2026-08",
Duration.ofSeconds(3),
8,
20);
}
private static NotificationPlatformSettings.Provider disabled(
NotificationPlatformSettings.Provider provider) {
return new NotificationPlatformSettings.Provider(
provider.type(),
false,
provider.primaryForChannel(),
provider.environment(),
provider.credentialProfile(),
provider.topic(),
provider.vapidPublicKey(),
provider.callbackSigningSecretRef(),
provider.timeout(),
provider.maxConcurrency(),
provider.ratePerSecond());
}
}
@@ -301,5 +301,24 @@ class LeaseRecoveryServiceTest {
transitions.add(Map.entry(id, state));
return null;
}
@Override
public Optional<RecipientDeliveryRecord> saveHeldBy(
RecipientDeliveryRecord record,
dev.caskeleton.application.notification.platform.dispatch.RecipientLease lease) {
return Optional.of(save(record));
}
@Override
public Optional<RecipientDeliveryRecord> transitionHeldBy(
RecipientDeliveryId id,
RecipientDeliveryState state,
Optional<Instant> nextDispatchAt,
dev.caskeleton.application.notification.platform.dispatch.RecipientLease lease) {
// This fake belongs to lease *recovery*, which runs for jobs whose holder is gone; the fenced
// variants are the dispatch path's and are not exercised here.
transition(id, state, nextDispatchAt);
return Optional.empty();
}
}
}
@@ -0,0 +1,126 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesProviderProperties;
import dev.caskeleton.adapter.outbound.notification.platform.provider.webhook.WebhookSubscription;
import java.net.URI;
import java.time.Duration;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* The endpoint guard is reached from the places that need it.
*
* <p>{@code EndpointRoutabilityTest} already proves {@code requireExternallyRoutable} rejects the
* metadata service, RFC 1918, link-local and the rest. It proved that for months while the function
* had no caller: both sites it was written for — a webhook target and an SES endpoint — kept
* calling {@code requireSecureOrLoopback}, which reads the scheme and nothing else. A green test on
* a control nothing invokes is the shape this repository keeps finding, and testing the helper
* again would not have caught it.
*
* <p>So these assertions go through the constructors an operator and a caller actually reach.
*
* <p>The loopback allowance is part of that. It was a constant {@code true} at both call sites,
* which left a client-supplied target naming {@code localhost} accepted — the residue this finding
* carried until the allowance became {@code trusted}, the flag the record already used to decide
* whether the same target may inherit a platform signing key.
*/
class EndpointGuardCallSiteTest {
private static final URI METADATA = URI.create("https://169.254.169.254/latest/meta-data/");
private static final URI PRIVATE_NETWORK = URI.create("https://10.0.0.5/hook");
@Test
@DisplayName("a webhook target on the cloud metadata service is refused")
void aWebhookTargetOnTheMetadataServiceIsRefused() {
assertThatThrownBy(() -> new WebhookSubscription("sub-1", METADATA, false, Optional.empty()))
.as("a client-supplied target that fetches instance credentials is the SSRF this guards")
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("a webhook target inside the deployment's own network is refused")
void aWebhookTargetOnAPrivateAddressIsRefused() {
assertThatThrownBy(
() -> new WebhookSubscription("sub-1", PRIVATE_NETWORK, true, Optional.empty()))
.as("trusted decides credential inheritance, not whether an internal address is reachable")
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("a webhook target carrying userinfo is refused")
void aWebhookTargetWithUserinfoIsRefused() {
assertThatThrownBy(
() ->
new WebhookSubscription(
"sub-1",
URI.create("https://evil.example.com@127.0.0.1/hook"),
true,
Optional.empty()))
.as("the text before '@' is what a log reader takes for the host")
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("an SES endpoint inside the deployment's own network is refused")
void anSesEndpointOnAPrivateAddressIsRefused() {
assertThatThrownBy(
() ->
new SesProviderProperties(
PRIVATE_NETWORK,
"ap-northeast-2",
"transactional@example.com",
Optional.empty(),
Duration.ofSeconds(3)))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("a client-supplied webhook target on the loopback interface is refused")
void aDynamicWebhookTargetOnLoopbackIsRefused() {
assertThatThrownBy(
() ->
new WebhookSubscription(
"sub-1", URI.create("https://localhost/hook"), false, Optional.empty()))
.as(
"the loopback allowance was a constant `true`, so the one case the guard could not "
+ "cover was a user-supplied target that simply named localhost")
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("a client-supplied webhook target on 127.0.0.1 is refused")
void aDynamicWebhookTargetOnTheLoopbackAddressIsRefused() {
assertThatThrownBy(
() ->
new WebhookSubscription(
"sub-1", URI.create("http://127.0.0.1:8080/hook"), false, Optional.empty()))
.as("naming the address rather than the host must not be the way around the refusal")
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("loopback stays available, because local and contract profiles address it")
void loopbackIsStillAccepted() {
assertThatCode(
() ->
new WebhookSubscription(
"sub-1", URI.create("http://127.0.0.1:8080/hook"), true, Optional.empty()))
.as(
"this is the case the allowance exists for, and it is the reason the refusal above "
+ "has to be conditional rather than absolute")
.doesNotThrowAnyException();
assertThatCode(
() ->
new SesProviderProperties(
URI.create("http://localhost:4566"),
"ap-northeast-2",
"transactional@example.com",
Optional.empty(),
Duration.ofSeconds(3)))
.doesNotThrowAnyException();
}
}
@@ -1,25 +1,49 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.notification.platform.provider.UnconfiguredAttachmentResolver;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpMimeMessageFactory;
import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
import dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager;
import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderAdapterContract;
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness;
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures;
import dev.caskeleton.application.notification.platform.api.content.AttachmentDisposition;
import dev.caskeleton.application.notification.platform.api.content.AttachmentRef;
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
import dev.caskeleton.application.notification.platform.api.content.EmailOptions;
import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel;
import dev.caskeleton.application.notification.platform.api.error.AttachmentIntegrityException;
import dev.caskeleton.application.notification.platform.api.error.AttachmentUnavailableException;
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
import dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard;
import dev.caskeleton.application.notification.platform.provider.AttachmentResolver;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import jakarta.mail.Session;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -39,21 +63,8 @@ class SesNotificationProviderAdapterTest extends ProviderAdapterContract {
@Override
protected NotificationProviderAdapter adapter() {
var properties =
new SesProviderProperties(
harness.baseUri(),
"ap-northeast-2",
"transactional@example.com",
Optional.empty(),
Duration.ofSeconds(3));
return new SesNotificationProviderAdapter(
new JdkNotificationHttpGateway(Duration.ofSeconds(2)),
new SesRequestMapper(properties, new AwsSignatureV4Signer()),
new SesFailureClassifier(),
protector,
SecurityFixtures.keys(),
"AKIAEXAMPLE",
CLOCK);
return adapter(
SecurityFixtures.credentials("ses-primary"), new UnconfiguredAttachmentResolver());
}
@Override
@@ -101,4 +112,241 @@ class SesNotificationProviderAdapterTest extends ProviderAdapterContract {
assertThat(recorded.header("X-Amz-Content-Sha256")).isPresent();
assertThat(recorded.uri().toString()).doesNotContain(ProviderFixtures.SECRET_EMAIL);
}
@Test
void contentWithNoAttachmentStaysOnTheSimpleShape() {
harness.respondWith(200, successBody(), Map.of());
adapter().submit(submission()).toCompletableFuture().join();
var content = requestContent(0);
assertThat(content.get("Simple")).isNotNull();
assertThat(content.get("Raw")).isNull();
}
@Test
void aDeclaredAttachmentIsCarriedAsRawMimeContent() {
byte[] bytes = documentBytes();
harness.respondWith(200, successBody(), Map.of());
var result =
adapter(SecurityFixtures.credentials("ses-primary"), resolverReturning(bytes))
.submit(submissionWithAttachment(bytes))
.toCompletableFuture()
.join();
assertThat(result.evidenceLevel()).isEqualTo(EvidenceLevel.PROVIDER_ACCEPTED);
var content = requestContent(0);
assertThat(content.get("Simple"))
.as("Simple content has no MIME part, so the declared attachment was simply not sent")
.isNull();
String mime =
new String(
Base64.getDecoder().decode(content.get("Raw").get("Data").asString()),
StandardCharsets.UTF_8);
assertThat(mime).contains("Contract subject");
assertThat(mime).contains("invoice.pdf");
assertThat(mime)
.as("the document itself, base64 encoded as a binary part rather than merely named")
.contains(Base64.getEncoder().encodeToString(bytes));
assertThat(harness.received().get(0).header("Authorization").orElseThrow())
.startsWith("AWS4-HMAC-SHA256");
}
@Test
void anAttachmentThatCannotBeResolvedIsRefusedBeforeAnythingIsSent() {
byte[] bytes = documentBytes();
harness.respondWith(200, successBody(), Map.of());
assertThatThrownBy(
() -> adapter().submit(submissionWithAttachment(bytes)).toCompletableFuture().join())
.as("a mail that silently loses its attachment is worse than one that is not sent")
.isInstanceOf(AttachmentUnavailableException.class);
assertThat(harness.received()).isEmpty();
}
@Test
void attachmentBytesThatAreNotTheApprovedBytesAreRefusedBeforeAnythingIsSent() {
byte[] approved = "invoice-bytes".getBytes(StandardCharsets.UTF_8);
byte[] substituted = "1nvo1ce-bytes".getBytes(StandardCharsets.UTF_8);
harness.respondWith(200, successBody(), Map.of());
assertThatThrownBy(
() ->
adapter(SecurityFixtures.credentials("ses-primary"), resolverReturning(substituted))
.submit(submissionWithAttachment(approved))
.toCompletableFuture()
.join())
.as("same size, different bytes: only the digest separates them")
.isInstanceOf(AttachmentIntegrityException.class);
assertThat(harness.received()).isEmpty();
}
@Test
void aMessageLargerThanSesAcceptsIsRefusedBeforeItIsSigned() {
// Base64 transfer encoding adds a third, so this clears the limit as bytes and exceeds it as a
// message — which is exactly the case a check against the declared attachment size misses.
byte[] oversized = new byte[8 * 1_000_000];
assertThat(oversized.length).isLessThan((int) SesRequestMapper.MAX_MESSAGE_BYTES);
harness.respondWith(200, successBody(), Map.of());
assertThatThrownBy(
() ->
adapter(SecurityFixtures.credentials("ses-primary"), resolverReturning(oversized))
.submit(submissionWithAttachment(oversized))
.toCompletableFuture()
.join())
.isInstanceOf(ProviderPayloadLimitException.class);
assertThat(harness.received()).isEmpty();
}
@Test
void twoProfilesAreSignedWithTheirOwnCredential() {
// One submission, sent twice: the SES request body is derived from content and recipient alone,
// so anything that differs between the two signatures is the credential.
var submission = submission();
harness.respondWith(200, successBody(), Map.of());
adapter(
SecurityFixtures.credentials("ses-primary", "cred-1"),
new UnconfiguredAttachmentResolver())
.submit(submission)
.toCompletableFuture()
.join();
harness.respondWith(200, successBody(), Map.of());
adapter(
SecurityFixtures.credentials("ses-primary", "cred-2"),
new UnconfiguredAttachmentResolver())
.submit(submission)
.toCompletableFuture()
.join();
assertThat(harness.received().get(1).header("Authorization"))
.as(
"every profile signed with the platform's one current provider credential, so a leak "
+ "of one SES account's key was a leak of every provider account")
.isNotEqualTo(harness.received().get(0).header("Authorization"));
}
@Test
void aProfileWithNoActivatedCredentialIsRefusedBeforeTheRequest() {
harness.respondWith(200, successBody(), Map.of());
assertThatThrownBy(
() ->
adapter(
SecurityFixtures.credentials("some-other-profile"),
new UnconfiguredAttachmentResolver())
.submit(submission())
.toCompletableFuture()
.join())
.isInstanceOf(IllegalStateException.class);
assertThat(harness.received()).isEmpty();
}
private SesNotificationProviderAdapter adapter(
ProviderCredentialManager credentials, AttachmentResolver attachments) {
var properties =
new SesProviderProperties(
harness.baseUri(),
"ap-northeast-2",
"transactional@example.com",
Optional.empty(),
Duration.ofSeconds(3));
return new SesNotificationProviderAdapter(
new JdkNotificationHttpGateway(Duration.ofSeconds(2)),
new SesRequestMapper(
properties,
new AwsSignatureV4Signer(),
new SmtpMimeMessageFactory(Session.getInstance(new Properties()))),
new AttachmentIntegrityGuard(attachments),
new SesFailureClassifier(),
protector,
credentials,
"AKIAEXAMPLE",
CLOCK);
}
/** The {@code Content} object of a recorded request. */
private tools.jackson.databind.JsonNode requestContent(int index) {
return NotificationJsonMapper.mapper()
.readTree(harness.received().get(index).bodyAsString())
.get("Content");
}
/**
* A resolver that hands back exactly these bytes and describes them honestly.
*
* <p>The digest is computed from what is returned rather than copied from the reference, so a
* resolver that returns something other than the approved bytes is caught by the integrity guard
* instead of being waved through by a fixture that agrees with itself.
*/
private static AttachmentResolver resolverReturning(byte[] bytes) {
return (reference, context) ->
new ResolvedAttachment(
new ByteArrayInputStream(bytes),
bytes.length,
digestOf(bytes),
reference.contentType(),
reference.displayName());
}
private ProviderSubmission submissionWithAttachment(byte[] approvedBytes) {
return ProviderFixtures.submission(
ProviderFixtures.profile("ses-primary", "ses", Channel.EMAIL),
Channel.EMAIL,
new EmailContent(
"Contract subject",
"Contract body",
Optional.empty(),
List.of(
new AttachmentRef(
"storage://bucket/invoice.pdf",
"invoice.pdf",
"application/pdf",
approvedBytes.length,
digestOf(approvedBytes),
AttachmentDisposition.ATTACHMENT)),
EmailOptions.DEFAULT),
protector,
EmailAddress.parse(ProviderFixtures.SECRET_EMAIL),
Optional.of(CLOCK.instant().plus(Duration.ofHours(1))));
}
/**
* A short binary document.
*
* <p>Binary rather than text on purpose: MIME encodes an ASCII part as {@code 7bit} and leaves it
* legible, which would let the assertion pass on a part that was never really encoded at all.
*/
private static byte[] documentBytes() {
return new byte[] {
'%',
'P',
'D',
'F',
'-',
'1',
'.',
'7',
'\n',
(byte) 0x80,
(byte) 0xC3,
0x00,
0x01,
0x02,
(byte) 0xFF
};
}
private static String digestOf(byte[] bytes) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (NoSuchAlgorithmException unavailable) {
throw new IllegalStateException("SHA-256 is required by every supported JRE", unavailable);
}
}
}
@@ -0,0 +1,121 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures;
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import jakarta.mail.Session;
import jakarta.mail.internet.MimeMessage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.Properties;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* That an attached document arrives with its bytes in it.
*
* <p>The factory handed JavaMail the resolver's stream directly. JavaMail reads an attachment twice
* — once to choose the part's transfer encoding, once to write the part — and the second read of an
* already drained stream returns nothing, so the message went out announcing a filename and
* carrying no content, and the attempt was recorded as accepted. Nothing noticed because every test
* asserted on the outcome of the send rather than on what was sent.
*
* <p>Asserted on the serialised message, because that is the only place the defect was visible: the
* part existed, its headers were right, and its body was empty.
*/
class SmtpAttachmentBodyTest {
private static final byte[] DOCUMENT = "invoice-body-bytes".getBytes(StandardCharsets.UTF_8);
private final ContactPointProtector protector =
new AesGcmContactPointProtector(SecurityFixtures.keys());
private final SmtpMimeMessageFactory factory =
new SmtpMimeMessageFactory(Session.getInstance(new Properties()));
@Test
@DisplayName("an attached document is written into the message, not just named by it")
void anAttachedDocumentCarriesItsBytes() throws Exception {
MimeMessage message =
factory.create(
submission(),
"recipient@example.test",
"sender@example.test",
List.of(attachment(DOCUMENT, DOCUMENT.length)));
assertThat(attachmentBytesOf(message))
.as("the part announced a filename and carried nothing")
.isEqualTo(DOCUMENT);
}
@Test
@DisplayName("content that is not the size the guard approved is refused")
void contentThatIsNotTheApprovedSizeIsRefused() {
// The integrity guard pins a size against the reference before the stream is handed over, so a
// stream that turns out to be a different length is not the document that was approved —
// whatever digest travelled with it.
assertThatThrownBy(
() ->
factory.create(
submission(),
"recipient@example.test",
"sender@example.test",
List.of(attachment(DOCUMENT, DOCUMENT.length + 1))))
.isInstanceOf(RuntimeException.class);
}
private static ResolvedAttachment attachment(byte[] content, long declaredSize) {
return new ResolvedAttachment(
new ByteArrayInputStream(content),
declaredSize,
"sha-256:not-checked-here",
"application/pdf",
"invoice.pdf");
}
/**
* The bytes of the attachment part, read back the way a receiving client reads them.
*
* <p>Read from the serialised message rather than from the part object, because the defect was
* exactly that the object described a part the serialisation could not fill: assertions taken
* before {@code writeTo} saw an attachment that was about to be written empty.
*/
private static byte[] attachmentBytesOf(MimeMessage message) throws Exception {
ByteArrayOutputStream wire = new ByteArrayOutputStream();
message.writeTo(wire);
MimeMessage received =
new MimeMessage(
Session.getInstance(new Properties()),
new java.io.ByteArrayInputStream(wire.toByteArray()));
jakarta.mail.internet.MimeMultipart parts =
(jakarta.mail.internet.MimeMultipart) received.getContent();
for (int index = 0; index < parts.getCount(); index++) {
jakarta.mail.BodyPart part = parts.getBodyPart(index);
if ("invoice.pdf".equals(part.getFileName())) {
return part.getInputStream().readAllBytes();
}
}
throw new AssertionError("the message carries no attachment part at all");
}
private ProviderSubmission submission() {
return ProviderFixtures.submission(
ProviderFixtures.profile("smtp-primary", "smtp", Channel.EMAIL),
Channel.EMAIL,
ProviderFixtures.email(),
protector,
EmailAddress.parse(ProviderFixtures.SECRET_EMAIL),
Optional.empty());
}
}
@@ -29,6 +29,7 @@ class TwilioCallbackAndProjectionTest {
Optional.of("MG123"),
Optional.empty(),
CALLBACK_URL,
"cb-1",
java.time.Duration.ofSeconds(3),
java.time.Duration.ofHours(12));
@@ -54,6 +55,47 @@ class TwilioCallbackAndProjectionTest {
assertThat(events.get(0).providerRequestId()).contains("SM1");
}
@Test
void twoProfilesVerifyWithTheirOwnSigningKey() {
Map<String, String> parameters =
new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered"));
var request = callback(parameters, signature(parameters));
// Signed with cb-1, presented to a profile whose reference names cb-2. Verification used the
// platform's one current callback signing key, so every Twilio profile shared one secret and a
// subaccount whose token leaked could forge status callbacks for any other.
assertThat(adapterWithSigningRef("cb-2").verify(request).valid()).isFalse();
assertThat(adapterWithSigningRef("cb-1").verify(request).valid()).isTrue();
}
@Test
void aSigningRefNamingAKeyOfAnotherPurposeIsRefusedBeforeAVerdict() {
Map<String, String> parameters =
new TreeMap<>(Map.of("MessageSid", "SM1", "MessageStatus", "delivered"));
var request = callback(parameters, signature(parameters));
org.assertj.core.api.Assertions.assertThatThrownBy(
() -> adapterWithSigningRef("enc-1").verify(request))
.as("a misfiled reference is a configuration fault, not a signature that never matches")
.isInstanceOf(IllegalStateException.class);
}
private TwilioCallbackAdapter adapterWithSigningRef(String signingKeyRef) {
return new TwilioCallbackAdapter(
new TwilioSignatureValidator(),
new TwilioStatusNormalizer(),
new TwilioProviderProperties(
java.net.URI.create("https://api.twilio.example"),
"AC123",
Optional.of("MG123"),
Optional.empty(),
CALLBACK_URL,
signingKeyRef,
java.time.Duration.ofSeconds(3),
java.time.Duration.ofHours(12)),
SecurityFixtures.keys());
}
@Test
void invalidSignatureIsRejected() {
Map<String, String> parameters =
@@ -29,6 +29,7 @@ class TwilioCallbackContractTest extends CallbackContract {
Optional.of("MG123"),
Optional.empty(),
CALLBACK_URL,
"cb-1",
Duration.ofSeconds(3),
Duration.ofHours(12));
@@ -39,6 +39,7 @@ class TwilioSmsProviderAdapterTest extends ProviderAdapterContract {
Optional.of("MG123"),
Optional.empty(),
"https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary",
"cb-1",
Duration.ofSeconds(3),
Duration.ofHours(12));
}
@@ -50,7 +51,7 @@ class TwilioSmsProviderAdapterTest extends ProviderAdapterContract {
new TwilioRequestMapper(properties()),
new TwilioFailureClassifier(),
protector,
SecurityFixtures.keys());
SecurityFixtures.credentials("twilio-primary"));
}
@Override
@@ -1,23 +1,37 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.webhook;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
import dev.caskeleton.adapter.outbound.notification.platform.security.SecurityFixtures;
import dev.caskeleton.adapter.outbound.notification.platform.security.SettingsSecretMaterialProvider;
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFaultHarness;
import dev.caskeleton.adapter.outbound.notification.platform.testkit.ProviderFixtures;
import dev.caskeleton.application.notification.platform.api.content.InAppContent;
import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.InAppRecipientRef;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.net.URI;
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 java.util.Optional;
import java.util.function.Function;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -37,14 +51,52 @@ class WebhookNotificationProviderAdapterTest {
@Test
void dynamicTargetNeverInheritsTrustedCredentials() {
harness.respondWith(200, "{}", Map.of());
// Both gateway arguments used to be the same instance, so "the request carried no
// Authorization header" was a property of the test's own wiring rather than of the adapter:
// there was nothing in the graph that could have added one, and the assertion would have held
// just as well for a trusted subscription. What actually decides credential inheritance is
// which of the two gateways the adapter hands the request to, and that is only observable when
// they are distinguishable.
//
// The target is a routable literal rather than the local harness because a client-supplied
// target may no longer name the loopback interface (NTF-012). A literal address also keeps the
// constructor's resolution check off DNS.
RecordingGateway trusted = new RecordingGateway();
RecordingGateway dynamic = new RecordingGateway();
WebhookSubscription subscription =
new WebhookSubscription("sub-2", ROUTABLE_TARGET, false, Optional.empty());
adapter(dynamicSubscription()).submit(submission()).toCompletableFuture().join();
adapter(trusted, dynamic, submission -> subscription)
.submit(submission())
.toCompletableFuture()
.join();
var recorded = harness.received().get(0);
assertThat(recorded.header("Authorization")).isEmpty();
assertThat(recorded.header("Cookie")).isEmpty();
assertThat(recorded.header(WebhookSignatureStrategy.SIGNATURE_HEADER)).isEmpty();
assertThat(trusted.exchanged)
.as("a client-supplied target must not reach the gateway that carries platform credentials")
.isEmpty();
assertThat(dynamic.exchanged).hasSize(1);
assertThat(dynamic.exchanged.get(0).headers())
.doesNotContainKeys("authorization", "cookie", WebhookSignatureStrategy.SIGNATURE_HEADER);
}
@Test
void trustedTargetReachesTheCredentialedGateway() {
// The counterpart, so the assertion above cannot pass by the adapter never reaching either
// gateway, and so the routing rule is watched working in both directions.
RecordingGateway trusted = new RecordingGateway();
RecordingGateway dynamic = new RecordingGateway();
WebhookSubscription subscription =
new WebhookSubscription("sub-1", ROUTABLE_TARGET, true, Optional.of("cb-1"));
adapter(trusted, dynamic, submission -> subscription)
.submit(submission())
.toCompletableFuture()
.join();
assertThat(dynamic.exchanged).isEmpty();
assertThat(trusted.exchanged).hasSize(1);
assertThat(trusted.exchanged.get(0).headers())
.containsKey(WebhookSignatureStrategy.SIGNATURE_HEADER);
}
@Test
@@ -78,33 +130,160 @@ class WebhookNotificationProviderAdapterTest {
assertThat(result.failure().orElseThrow().nativeCode().orElseThrow().length()).isLessThan(1000);
}
@Test
void twoSubscriptionsWithDifferentKeyRefsAreSignedDifferently() {
// One submission for both deliveries: the attempt id is part of the body, so two submissions
// would differ in what was signed and the signatures would differ whatever key was used.
var submission = submission();
harness.respondWith(200, "{}", Map.of());
adapter(trustedSubscription("cb-1")).submit(submission).toCompletableFuture().join();
harness.respondWith(200, "{}", Map.of());
adapter(trustedSubscription("cb-2")).submit(submission).toCompletableFuture().join();
var first = harness.received().get(0).header(WebhookSignatureStrategy.SIGNATURE_HEADER);
var second = harness.received().get(1).header(WebhookSignatureStrategy.SIGNATURE_HEADER);
assertThat(first).isPresent();
assertThat(second)
.as(
"signingKeyRef only decided whether to sign; the signature came from one platform key, "
+ "so every trusted receiver could verify and forge every other receiver's webhook")
.isPresent()
.isNotEqualTo(first);
}
@Test
void aKeyRefThatIsNotACallbackSigningKeyIsRefusedBeforeTheRequest() {
assertThatThrownBy(
() ->
adapter(trustedSubscription("cred-1"))
.submit(submission())
.toCompletableFuture()
.join())
.isInstanceOf(IllegalStateException.class);
assertThat(harness.received()).isEmpty();
}
@Test
void aBodyOverTheDeclaredCeilingIsRefusedBeforeTheRequest() {
assertThatThrownBy(
() ->
adapter(trustedSubscription("cb-1"))
.submit(oversizedSubmission())
.toCompletableFuture()
.join())
.isInstanceOf(ProviderPayloadLimitException.class);
assertThat(harness.received())
.as("the capability declared a ceiling and nothing measured the bytes against it")
.isEmpty();
}
/**
* A target outside every range the endpoint guard refuses, written as a literal.
*
* <p>TEST-NET-3, which is reserved for documentation and routes nowhere — and being a literal, it
* is never looked up, so the guard's resolution step does not make these tests depend on DNS.
*/
private static final URI ROUTABLE_TARGET = URI.create("https://203.0.113.10/hook");
/** Records what it was asked to send and answers 200, so nothing is dialled. */
private static final class RecordingGateway implements NotificationHttpGateway {
private final List<NotificationHttpRequest> exchanged = new ArrayList<>();
@Override
public NotificationHttpResponse exchange(NotificationHttpRequest request) {
exchanged.add(request);
return new NotificationHttpResponse(
200, Map.of(), "{}".getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
}
private WebhookNotificationProviderAdapter adapter(
NotificationHttpGateway trustedGateway,
NotificationHttpGateway dynamicGateway,
Function<ProviderSubmission, WebhookSubscription> subscriptions) {
return new WebhookNotificationProviderAdapter(
trustedGateway,
dynamicGateway,
new WebhookSignatureStrategy(),
keys(),
subscriptions,
Duration.ofSeconds(3),
CLOCK);
}
private WebhookNotificationProviderAdapter adapter(WebhookSubscription subscription) {
var gateway = new JdkNotificationHttpGateway(Duration.ofSeconds(2));
return new WebhookNotificationProviderAdapter(
gateway,
gateway,
new WebhookSignatureStrategy(),
SecurityFixtures.keys(),
keys(),
submission -> subscription,
Duration.ofSeconds(3),
CLOCK);
}
private WebhookSubscription trustedSubscription() {
return new WebhookSubscription(
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of("callback-sign"));
/**
* Two callback signing keys, so a per-subscription reference has something to distinguish.
*
* <p>{@code cred-1} is present as well: a reference naming a key issued for another purpose is a
* configuration fault this adapter has to catch rather than sign with.
*/
private static SecretMaterialProvider keys() {
return new SettingsSecretMaterialProvider(
Map.of(
SecretPurpose.CALLBACK_SIGNING,
new SecretKeyMaterial("cb-1", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x33)),
SecretPurpose.CONTACT_ENCRYPTION,
new SecretKeyMaterial("enc-1", SecretPurpose.CONTACT_ENCRYPTION, filled((byte) 0x11)),
SecretPurpose.CONTACT_LOOKUP_HMAC,
new SecretKeyMaterial("mac-1", SecretPurpose.CONTACT_LOOKUP_HMAC, filled((byte) 0x22))),
Map.of(
"cb-2",
new SecretKeyMaterial("cb-2", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x34)),
"cred-1",
new SecretKeyMaterial(
"cred-1", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x44))));
}
private WebhookSubscription dynamicSubscription() {
private static byte[] filled(byte value) {
byte[] material = new byte[32];
java.util.Arrays.fill(material, value);
return material;
}
private WebhookSubscription trustedSubscription() {
return trustedSubscription("cb-1");
}
private WebhookSubscription trustedSubscription(String signingKeyRef) {
return new WebhookSubscription(
"sub-2", harness.baseUri().resolve("/hook"), false, Optional.empty());
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of(signingKeyRef));
}
private ProviderSubmission submission() {
return submission(ProviderFixtures.webhook());
}
private ProviderSubmission oversizedSubmission() {
return submission(
new InAppContent(
"Order shipped",
"x".repeat((int) WebhookNotificationProviderAdapter.MAX_BODY_BYTES + 1),
Optional.empty(),
java.util.List.of(),
"order"));
}
private ProviderSubmission submission(InAppContent content) {
return ProviderFixtures.submission(
ProviderFixtures.profile("webhook-main", "webhook", Channel.WEBHOOK),
Channel.WEBHOOK,
ProviderFixtures.webhook(),
content,
protector,
new InAppRecipientRef("user-1"),
Optional.empty());
@@ -49,7 +49,7 @@ class CallbackPayloadBoundTest {
() ->
new AesGcmCallbackPayloadProtection(
SecurityFixtures.keys(),
new java.security.SecureRandom(),
payloads(),
AesGcmCallbackPayloadProtection.MAX_CIPHERTEXT_BYTES))
.as("this is exactly the configuration that produced 65,564 bytes of ciphertext")
.isInstanceOf(IllegalArgumentException.class)
@@ -62,7 +62,7 @@ class CallbackPayloadBoundTest {
AesGcmCallbackPayloadProtection protection =
new AesGcmCallbackPayloadProtection(
SecurityFixtures.keys(),
new java.security.SecureRandom(),
payloads(),
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES);
byte[] stored =
@@ -78,7 +78,7 @@ class CallbackPayloadBoundTest {
AesGcmCallbackPayloadProtection protection =
new AesGcmCallbackPayloadProtection(
SecurityFixtures.keys(),
new java.security.SecureRandom(),
payloads(),
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES);
byte[] stored =
@@ -96,8 +96,13 @@ class CallbackPayloadBoundTest {
() ->
new AesGcmCallbackPayloadProtection(
SecurityFixtures.keys(),
new java.security.SecureRandom(),
payloads(),
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES))
.doesNotThrowAnyException();
}
private static AesGcmNotificationPayloadProtection payloads() {
return new AesGcmNotificationPayloadProtection(
SecurityFixtures.keys(), new java.security.SecureRandom());
}
}
@@ -0,0 +1,99 @@
package dev.caskeleton.adapter.outbound.notification.platform.security;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* What a retained callback is worth after the payload key rotates.
*
* <p>The retained raw body exists for one reason: a normalization bug is only diagnosable against
* what the provider actually sent. The stored bytes were a nonce and a ciphertext and nothing else,
* so the first rotation of the payload encryption key turned every retained callback into bytes
* that no key could be matched to — the retention outlived the key but not the ability to name it,
* which is the same as not retaining it.
*/
class CallbackPayloadRotationTest {
private static final byte[] RAW =
"{\"MessageId\":\"m-1\",\"eventType\":\"Delivery\"}".getBytes(StandardCharsets.UTF_8);
@Test
@DisplayName("a callback retained before a rotation is still readable after it")
void aCallbackRetainedBeforeARotationIsStillReadableAfterIt() {
byte[] stored = protection(beforeRotation()).protectRawPayload(RAW);
byte[] revealed =
new AesGcmNotificationPayloadProtection(afterRotation(), new SecureRandom()).reveal(stored);
assertThat(revealed)
.as("the envelope names the key it used, so the retired key can be asked for by id")
.isEqualTo(RAW);
}
@Test
@DisplayName("the retained bytes name the key that encrypted them")
void theRetainedBytesNameTheKeyThatEncryptedThem() {
byte[] stored = protection(beforeRotation()).protectRawPayload(RAW);
int keyIdLength = Byte.toUnsignedInt(stored[1]);
String keyId = new String(stored, 2, keyIdLength, StandardCharsets.UTF_8);
assertThat(stored[0])
.as("a format that cannot say which format it is can only change by rewriting every row")
.isEqualTo(AesGcmNotificationPayloadProtection.VERSION);
assertThat(keyId).isEqualTo("payload-1");
}
@Test
@DisplayName("the retained bytes are not the payload")
void theRetainedBytesAreNotThePayload() {
byte[] stored = protection(beforeRotation()).protectRawPayload(RAW);
assertThat(new String(stored, StandardCharsets.UTF_8)).doesNotContain("MessageId");
}
private static AesGcmCallbackPayloadProtection protection(SecretMaterialProvider keys) {
return new AesGcmCallbackPayloadProtection(
keys,
new AesGcmNotificationPayloadProtection(keys, new SecureRandom()),
AesGcmCallbackPayloadProtection.MAX_PLAINTEXT_BYTES);
}
/** The key store as it stood when the callback arrived. */
private static SecretMaterialProvider beforeRotation() {
return new SettingsSecretMaterialProvider(
Map.of(
SecretPurpose.PAYLOAD_ENCRYPTION,
payloadKey("payload-1", (byte) 0x55),
SecretPurpose.CALLBACK_FINGERPRINT_HMAC,
new SecretKeyMaterial(
"fp-1", SecretPurpose.CALLBACK_FINGERPRINT_HMAC, filled((byte) 0x88))),
Map.of());
}
/** The key store after the payload key was replaced and the old one retired. */
private static SecretMaterialProvider afterRotation() {
return new SettingsSecretMaterialProvider(
Map.of(SecretPurpose.PAYLOAD_ENCRYPTION, payloadKey("payload-2", (byte) 0x56)),
Map.of("payload-1", payloadKey("payload-1", (byte) 0x55)));
}
private static SecretKeyMaterial payloadKey(String keyId, byte fill) {
return new SecretKeyMaterial(keyId, SecretPurpose.PAYLOAD_ENCRYPTION, filled(fill));
}
private static byte[] filled(byte value) {
byte[] material = new byte[32];
Arrays.fill(material, value);
return material;
}
}
@@ -0,0 +1,171 @@
package dev.caskeleton.adapter.outbound.notification.platform.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.security.SecretKeyMaterial;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
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.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* A rotation as a window rather than an instant.
*
* <p>A superseded generation used to be overwritten outright. The attempts planned against it are
* already in flight when the rotation lands, and they are the ones that cannot simply be failed and
* cannot be replayed either — the provider may already have acted on the request. Retiring the
* credential at the moment the new one arrives fails exactly that work.
*
* <p>The opposite mistake is keeping it forever, which is not a rotation but two live credentials.
* The window is what makes the retirement real, so it is asserted from both ends.
*/
class CredentialDrainWindowTest {
private static final ProviderProfileId PROFILE = new ProviderProfileId("ses-primary");
private static final ProviderProfileId OTHER = new ProviderProfileId("ses-secondary");
private static final Instant START = Instant.parse("2026-08-19T00:00:00Z");
private static final Duration WINDOW = Duration.ofMinutes(15);
private final MovableClock clock = new MovableClock(START);
private final ProviderCredentialManager manager =
new ProviderCredentialManager(credentialKeys(), clock, WINDOW);
@Test
@DisplayName("a rotation switches new work to the new generation")
void aRotationSwitchesNewWorkToTheNewGeneration() {
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-2"));
assertThat(manager.current(PROFILE).orElseThrow().generation()).isEqualTo(2);
assertThat(manager.materialFor(PROFILE, 2)).isEqualTo(filled((byte) 0x44));
}
@Test
@DisplayName("in-flight work keeps the generation it was planned against")
void inFlightWorkKeepsTheGenerationItWasPlannedAgainst() {
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-2"));
clock.advance(WINDOW.minusSeconds(1));
assertThat(manager.materialFor(PROFILE, 1))
.as("an attempt the provider may already have acted on cannot be failed or replayed")
.isEqualTo(filled((byte) 0x33));
}
@Test
@DisplayName("the retired generation stops resolving once the window closes")
void theRetiredGenerationStopsResolvingOnceTheWindowCloses() {
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
manager.activate(CredentialGeneration.candidate(PROFILE, 2, "cred-2"));
clock.advance(WINDOW);
assertThatThrownBy(() -> manager.materialFor(PROFILE, 1))
.as("a superseded credential that never expires is not a rotation, it is two live keys")
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("finished draining");
}
@Test
@DisplayName("a generation that was never activated is refused, drained or not")
void aGenerationThatWasNeverActivatedIsRefused() {
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
assertThatThrownBy(() -> manager.materialFor(PROFILE, 7))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("no credential generation 7");
assertThatThrownBy(() -> manager.materialFor(OTHER, 1))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("no activated credential generation");
}
@Test
@DisplayName("two profiles resolve different material")
void twoProfilesResolveDifferentMaterial() {
manager.activate(CredentialGeneration.candidate(PROFILE, 1, "cred-1"));
manager.activate(CredentialGeneration.candidate(OTHER, 1, "cred-2"));
assertThat(manager.materialFor(PROFILE, 1))
.as("one platform-wide provider credential made a leak of one account a leak of all")
.isNotEqualTo(manager.materialFor(OTHER, 1));
}
@Test
@DisplayName("a handle naming a key of another purpose never becomes a credential")
void aHandleNamingAKeyOfAnotherPurposeIsRefused() {
assertThatThrownBy(
() -> manager.activate(CredentialGeneration.candidate(PROFILE, 1, "callback-1")))
.as("refused at the rotation, so no dispatch can ever resolve it")
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> manager.materialFor(PROFILE, 1))
.isInstanceOf(IllegalStateException.class);
}
@Test
@DisplayName("a negative drain window is refused")
void aNegativeDrainWindowIsRefused() {
assertThatThrownBy(
() -> new ProviderCredentialManager(credentialKeys(), clock, Duration.ofMinutes(-1)))
.isInstanceOf(IllegalArgumentException.class);
}
/** Two provider credentials and one key of another purpose, to prove the separation holds. */
private static SecretMaterialProvider credentialKeys() {
return new SettingsSecretMaterialProvider(
Map.of(
SecretPurpose.PROVIDER_CREDENTIAL,
new SecretKeyMaterial("cred-1", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x33)),
SecretPurpose.CALLBACK_SIGNING,
new SecretKeyMaterial(
"callback-1", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x55))),
Map.of(
"cred-2",
new SecretKeyMaterial(
"cred-2", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x44))));
}
private static byte[] filled(byte value) {
byte[] material = new byte[32];
Arrays.fill(material, value);
return material;
}
/** A clock the test moves, so the window is asserted rather than waited out. */
private static final class MovableClock extends Clock {
private Instant now;
private MovableClock(Instant now) {
this.now = now;
}
private void advance(Duration by) {
now = now.plus(by);
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return now;
}
}
}
@@ -35,7 +35,54 @@ public final class SecurityFixtures {
"fp-1", SecretPurpose.CALLBACK_FINGERPRINT_HMAC, filled((byte) 0x88, 32)),
SecretPurpose.VAPID_SIGNING,
new SecretKeyMaterial("vapid-1", SecretPurpose.VAPID_SIGNING, filled((byte) 0x66, 32))),
Map.of());
// A second provider credential, so a fixture can give two profiles genuinely different
// material rather than asserting per-profile binding against one shared key.
Map.of(
"cred-2",
new SecretKeyMaterial(
"cred-2", SecretPurpose.PROVIDER_CREDENTIAL, filled((byte) 0x45, 32)),
"cb-2",
new SecretKeyMaterial(
"cb-2", SecretPurpose.CALLBACK_SIGNING, filled((byte) 0x34, 32))));
}
/**
* A credential manager holding generation 1 of each named profile.
*
* <p>Adapters resolve a profile's credential rather than the platform's one current provider key,
* so a contract test has to say which profile it is speaking for — which is the point: a fixture
* that could not name a profile was a fixture proving a shape the platform no longer has.
*
* @param profileIds the profiles to activate
* @return the manager
*/
public static ProviderCredentialManager credentials(String... profileIds) {
var manager = new ProviderCredentialManager(keys(), java.time.Clock.systemUTC());
for (String profileId : profileIds) {
activate(manager, profileId, "cred-1");
}
return manager;
}
/**
* A credential manager holding generation 1 of one profile, backed by a named key.
*
* @param profileId the profile to activate
* @param keyId which provider credential it is bound to
* @return the manager
*/
public static ProviderCredentialManager credentials(String profileId, String keyId) {
var manager = new ProviderCredentialManager(keys(), java.time.Clock.systemUTC());
activate(manager, profileId, keyId);
return manager;
}
private static void activate(ProviderCredentialManager manager, String profileId, String keyId) {
manager.activate(
CredentialGeneration.candidate(
new dev.caskeleton.application.notification.platform.api.ProviderProfileId(profileId),
1,
keyId));
}
public static SecretMaterialProvider keysWithSameMaterial() {
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.outbound.notification.platform.template;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.notification.platform.api.error.TemplateRenderingException;
import java.util.Map;
import java.util.stream.Stream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.thymeleaf.templatemode.TemplateMode;
/**
* Every engine honours the slot mode, not just the one the tests happened to instantiate.
*
* <p>{@code SlotAwareRenderingTest} constructs {@code PlaceholderTemplateEngine} and only that, and
* the mode-aware method was a {@code default} that forwarded to the unescaped overload. The
* Thymeleaf engine never overrode it, so with {@code template.engine=thymeleaf} — a supported,
* documented value — a subject could carry CR/LF and a deep link could carry a {@code javascript:}
* scheme. Nothing failed, because the interface compiled and the one engine under test was the one
* that implemented the rule.
*
* <p>Parameterized over the engines for that reason: a rule that only holds for the implementation
* somebody remembered to test is not a rule the platform has.
*/
class BothEnginesHonourSlotModeTest {
static Stream<Arguments> engines() {
return Stream.of(
Arguments.of("placeholder", new PlaceholderTemplateEngine()),
Arguments.of("thymeleaf-html", new ThymeleafStringTemplateEngine(TemplateMode.HTML)),
Arguments.of("thymeleaf-text", new ThymeleafStringTemplateEngine(TemplateMode.TEXT)));
}
@ParameterizedTest(name = "{0}")
@MethodSource("engines")
@DisplayName("a newline in a subject is refused, whichever engine renders it")
void aSubjectMayNotCarryAControlCharacter(String name, NotificationTemplateEngine engine) {
assertThatThrownBy(
() ->
engine.render(
TemplateSlotMode.SUBJECT,
subjectTemplate(name),
Map.of("code", "123\r\nBcc: attacker@example.com")))
.as("everything after a CR/LF is read as a new header by the receiving agent")
.isInstanceOf(TemplateRenderingException.class);
}
@ParameterizedTest(name = "{0}")
@MethodSource("engines")
@DisplayName("a javascript: deep link is refused, whichever engine renders it")
void aDeepLinkMayNotUseAScriptScheme(String name, NotificationTemplateEngine engine) {
assertThatThrownBy(
() ->
engine.render(
TemplateSlotMode.URI,
linkTemplate(name),
Map.of("link", "javascript:alert(1)")))
.isInstanceOf(TemplateRenderingException.class);
}
@ParameterizedTest(name = "{0}")
@MethodSource("engines")
@DisplayName("an https deep link is accepted, so the rule is a filter and not a refusal")
void anHttpsDeepLinkIsAccepted(String name, NotificationTemplateEngine engine) {
// Without this, the assertion above is satisfied by an engine that refuses every URI slot.
assertThat(
engine.render(
TemplateSlotMode.URI, linkTemplate(name), Map.of("link", "https://example.com/a")))
.contains("https://example.com/a");
}
@ParameterizedTest(name = "{0}")
@MethodSource("engines")
@DisplayName("markup in an HTML slot is escaped, whichever engine renders it")
void markupInAnHtmlSlotIsEscaped(String name, NotificationTemplateEngine engine) {
assertThat(
engine.render(
TemplateSlotMode.HTML_TEXT,
bodyTemplate(name),
Map.of("name", "<script>x</script>")))
.as("a substituted value must not become markup")
.doesNotContain("<script>");
}
/** Each engine's own placeholder syntax; the rule under test is the escaping, not the syntax. */
private static String subjectTemplate(String engine) {
return engine.startsWith("thymeleaf") ? "code [[${code}]]" : "code {{code}}";
}
private static String linkTemplate(String engine) {
return engine.startsWith("thymeleaf") ? "[[${link}]]" : "{{link}}";
}
private static String bodyTemplate(String engine) {
return engine.startsWith("thymeleaf") ? "hello [[${name}]]" : "hello {{name}}";
}
}
@@ -107,10 +107,18 @@ public final class ContractAdapters {
var adapter =
new SesNotificationProviderAdapter(
new JdkNotificationHttpGateway(Duration.ofSeconds(2)),
new SesRequestMapper(properties, new AwsSignatureV4Signer()),
new SesRequestMapper(
properties,
new AwsSignatureV4Signer(),
new dev.caskeleton.adapter.outbound.notification.platform.provider.smtp
.SmtpMimeMessageFactory(
jakarta.mail.Session.getInstance(new java.util.Properties()))),
new dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard(
new dev.caskeleton.adapter.outbound.notification.platform.provider
.UnconfiguredAttachmentResolver()),
new SesFailureClassifier(),
protector,
SecurityFixtures.keys(),
SecurityFixtures.credentials("ses-primary"),
"AKIAEXAMPLE",
CLOCK);
return new Case(
@@ -133,6 +141,7 @@ public final class ContractAdapters {
Optional.of("MG123"),
Optional.empty(),
"https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary",
"cb-1",
Duration.ofSeconds(3),
Duration.ofHours(12));
var adapter =
@@ -141,7 +150,7 @@ public final class ContractAdapters {
new TwilioRequestMapper(properties),
new TwilioFailureClassifier(),
protector,
SecurityFixtures.keys());
SecurityFixtures.credentials("twilio-primary"));
return new Case(
"twilio",
adapter,
@@ -218,9 +227,11 @@ public final class ContractAdapters {
private static Case webhook(ProviderFaultHarness harness, ContactPointProtector protector) {
var gateway = new JdkNotificationHttpGateway(Duration.ofSeconds(2));
// The id of a real callback signing key, because a subscription is now signed with the key its
// reference names rather than with whatever the platform's current one happens to be.
var subscription =
new WebhookSubscription(
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of("callback-sign"));
"sub-1", harness.baseUri().resolve("/hook"), true, Optional.of("cb-1"));
var adapter =
new WebhookNotificationProviderAdapter(
gateway,
@@ -8,36 +8,11 @@
// root dependencyManagement block stays awssdk-free), mirroring the grpc module's grpc-bom import.
description = 'Outbound adapter: object storage (S3/MinIO + local filesystem)'
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
sourceSets {
objectStorageMinioContractTest {
java.srcDir 'src/objectStorageMinioContractTest/java'
resources.srcDir 'src/objectStorageMinioContractTest/resources'
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += output + compileClasspath
}
objectStorageMinioFaultTest {
java.srcDir 'src/objectStorageMinioFaultTest/java'
resources.srcDir 'src/objectStorageMinioFaultTest/resources'
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += output + compileClasspath
}
objectStorageAwsQualificationTest {
java.srcDir 'src/objectStorageAwsQualificationTest/java'
resources.srcDir 'src/objectStorageAwsQualificationTest/resources'
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += output + compileClasspath
}
}
configurations {
objectStorageMinioContractTestImplementation.extendsFrom testImplementation
objectStorageMinioContractTestRuntimeOnly.extendsFrom testRuntimeOnly
objectStorageMinioFaultTestImplementation.extendsFrom testImplementation
objectStorageMinioFaultTestRuntimeOnly.extendsFrom testRuntimeOnly
objectStorageAwsQualificationTestImplementation.extendsFrom testImplementation
objectStorageAwsQualificationTestRuntimeOnly.extendsFrom testRuntimeOnly
strictTestLanes {
sourceSet('objectStorageMinioContractTest') { compilesAgainst 'main', 'test' }
sourceSet('objectStorageMinioFaultTest') { compilesAgainst 'main', 'test' }
sourceSet('objectStorageAwsQualificationTest') { compilesAgainst 'main', 'test' }
}
dependencyManagement {
@@ -5,41 +5,20 @@
// Flyway migrations live only under the .postgresql subpackage (ArchUnit keeps the base neutral).
// The JPA relational persistence platform (docs/superpowers/specs/2026-08-11-jpa-persistence-
// platform-design.md) models itself as 18 Stable library modules. This repository's fail-closed
// 19-leaf registry outranks that layout, so those modules are packages here and
// module registry outranks that layout, so those modules are packages here and
// JpaModuleBoundaryTest enforces the design's module dependency table. The full mapping is in
// docs/jpa/repository-adaptation.md.
//
// The testkit is its own source set rather than part of `test` because more than one lane consumes
// it and because a source set whose dependencies are declared only on the test configurations gives
// the design's "no production module depends on the testkit" guarantee without a new Gradle project.
sourceSets {
postgresqlIntegrationTest {
java.setSrcDirs(['src/postgresqlIntegrationTest/java'])
resources.setSrcDirs(['src/postgresqlIntegrationTest/resources'])
compileClasspath += sourceSets.main.output
runtimeClasspath += output + compileClasspath
strictTestLanes {
sourceSet('postgresqlIntegrationTest') {
compilesAgainst 'main'
inherits 'implementation', 'compileOnly', 'runtimeOnly', 'annotationProcessor'
}
testkit {
java.srcDir 'src/testkit/java'
compileClasspath += sourceSets.main.output
runtimeClasspath += output + compileClasspath
}
jpaPlatformPerformanceTest {
java.srcDir 'src/jpaPlatformPerformanceTest/java'
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
runtimeClasspath += output + compileClasspath
}
}
configurations {
postgresqlIntegrationTestImplementation.extendsFrom testImplementation
postgresqlIntegrationTestCompileOnly.extendsFrom testCompileOnly
postgresqlIntegrationTestRuntimeOnly.extendsFrom testRuntimeOnly
postgresqlIntegrationTestAnnotationProcessor.extendsFrom testAnnotationProcessor
testkitImplementation.extendsFrom testImplementation
testkitRuntimeOnly.extendsFrom testRuntimeOnly
jpaPlatformPerformanceTestImplementation.extendsFrom testImplementation
jpaPlatformPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly
sourceSet('testkit') { compilesAgainst 'main' }
sourceSet('jpaPlatformPerformanceTest') { compilesAgainst 'main', 'testkit' }
}
// The testkit as a consumable artifact.
@@ -49,30 +28,9 @@ configurations {
// "domain must not depend on Hibernate" were verified as library code and applied to nothing. The
// composition root is the only place that can see every runtime leaf at once, so it is where the
// production suite belongs — and it needs the rules.
tasks.register('testkitJar', Jar) {
archiveClassifier = 'testkit'
from sourceSets.testkit.output
}
configurations {
jpaTestkit {
canBeConsumed = true
canBeResolved = false
}
}
artifacts {
jpaTestkit(tasks.named('testkitJar', Jar))
}
// Every test lane compiles and runs against the testkit.
sourceSets.test {
compileClasspath += sourceSets.testkit.output
runtimeClasspath += sourceSets.testkit.output
}
sourceSets.postgresqlIntegrationTest {
compileClasspath += sourceSets.testkit.output
runtimeClasspath += sourceSets.testkit.output
testkitPublisher {
consumedBy 'test', 'postgresqlIntegrationTest'
publishAs 'jpaTestkit'
}
ext.jpaPostgreSqlEvidenceImage = 'postgres:16-alpine'
@@ -313,10 +271,12 @@ def jpaPlatformSecurityTest = registerJpaPlatformLane(
// The pool behaviour contract. Named for what it does.
//
// It was `jpaPlatformPerformanceTest`, described as certifying pool and REQUIRES_NEW pressure, and
// gated by `performance.assertions.enabled` — which defaulted to false everywhere, including in the
// nightly workflow that set it explicitly to false. So the release gate depended on a lane whose
// only threshold assertion was that thresholds were not being asserted. "Certified" described a
// run in which no latency or throughput bound was ever compared to anything.
// gated behind a boolean that defaulted to off everywhere it appeared — in this file, and in the
// nightly workflow that set it explicitly to off. So the release gate depended on a lane whose only
// threshold assertion was that thresholds were not being asserted, and "certified" described a run
// in which no latency or throughput bound was ever compared to anything. The property is gone; its
// name is deliberately not repeated here, because a name in a comment is the next thing somebody
// tries to set.
//
// What the lane genuinely verifies is a behaviour contract: a REQUIRES_NEW depth of one needs two
// connections per concurrent thread, a saturated pool reports its pending count, and a caller waits
@@ -349,4 +309,48 @@ tasks.register('jpaPlatformReleaseGate') {
dependsOn jpaPlatformPoolContractTest
}
// The unit lane reads three files that are not Java sources: the release registry and its two
// renderings. Without declaring them, Gradle calls the lane up-to-date after a registry demotion or
// a workflow edit — so the drift check that exists to catch exactly that edit never runs on it.
// The unit lane reads files that are not Java sources: the release registry, its two renderings,
// and the documents that describe the pool lane. Without declaring them, Gradle calls the lane
// up-to-date after a registry demotion or a workflow edit — so the drift checks that exist to catch
// exactly those edits never run on them.
tasks.named('test') {
inputs.file(rootProject.file('config/jpa/release-registry.json'))
inputs.file(new File(rootProject.projectDir.parentFile, 'docs/jpa/support-matrix.md'))
inputs.file(new File(rootProject.projectDir.parentFile, 'docs/jpa/repository-adaptation.md'))
// The whole directory, not the two named workflows: the Experimental-major check asks whether
// *some* lane records that major as its target, so adding or deleting any workflow can change
// its answer. Naming files here would leave the lane that matters outside the up-to-date check.
inputs.dir(new File(rootProject.projectDir.parentFile, '.github/workflows'))
inputs.file(file('build.gradle'))
inputs.dir(file('src/jpaPlatformPerformanceTest/java'))
}
// verifyJpaApiSurface — every public type this leaf exposes is a committed decision.
//
// The GraphQL and Mongo leaves already carried this; the largest of the three did not, so the one
// public surface with the most adopters was the one nothing had an opinion about. The convention
// plugin is opt-in per leaf, and opting in was simply never done here.
//
// This is a record, not a budget, and the distinction matters. A snapshot shrinks nothing on its
// own: the GraphQL surface grew from 373 types to 398 while under one, each addition approved and
// none refused. What the baseline buys is that growth is visible in review at the moment it
// happens and that the number is available to argue with — not that the number cannot rise. A
// ceiling the approval flag cannot lift is a separate decision nobody has taken yet.
// `api` is the surface an adopter is meant to reach; everything else here is a candidate to become
// internal at that point.
apiSurface {
label = 'Jpa'
baseline = rootProject.file('../docs/architecture/jpa-api-surface.txt')
description = 'JPA persistence leaf public API surface — every public top-level type in src/main/java.'
rationale = [
'A public type in a single-jar leaf is reachable from every adopter\'s code, so',
'additions are reviewed rather than discovered. `api` is the intended external',
'surface; the rest is implementation that has not been moved under an internal',
'root yet.',
]
}
apply from: rootProject.file('gradle/jpa-evidence.gradle')
@@ -8,16 +8,18 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Pool pressure certification (design §38).
* The pool behaviour a {@code REQUIRES_NEW} deployment has to satisfy (design §38).
*
* <p>The lane reports rather than asserts unless {@code performance.assertions.enabled} is set,
* because the numbers depend on the machine. A shared CI runner producing a red build for a
* threshold it never had the resources to meet teaches people to ignore the lane.
* <p>This lane certifies nothing, and used to say it did. It was described as pool pressure
* certification and gated behind a flag that defaulted to false everywhere it appeared, including
* the nightly job that set it to false explicitly — so "certified" named a run in which no latency
* or throughput bound was ever compared to anything.
*
* <p>What is asserted unconditionally is the arithmetic the pool has to satisfy. With {@code
* REQUIRES_NEW}, a thread holds the outer transaction's connection while acquiring a second one, so
* a pool sized for the thread count alone deadlocks with every connection held by a thread waiting
* for another connection.
* <p>What is asserted is the arithmetic, which is true on any machine and therefore needs no flag.
* With {@code REQUIRES_NEW}, a thread holds the outer transaction's connection while acquiring a
* second one, so a pool sized for the thread count alone deadlocks with every connection held by a
* thread waiting for another connection. A real performance gate needs a dedicated runner, warmup
* and sample counts and recorded thresholds; when that exists it belongs in a lane of its own.
*/
class PoolPressureContractTest {
@@ -1,5 +1,7 @@
package dev.caskeleton.adapter.outbound.persistence.experimental.rls;
import dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeature;
import dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeatureGate;
import dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId;
import jakarta.persistence.EntityManager;
import java.util.Objects;
@@ -31,6 +33,27 @@ public final class RlsTenantSessionBinder {
/** Transaction-local binding; the {@code true} argument is what scopes it to the transaction. */
private static final String BIND_SQL = "select set_config('app.tenant_id', ?, true)";
/**
* The only way to obtain one.
*
* <p>The gate is asked before anything is constructed, so a deployment that never set the flag
* cannot end up holding an instance. Taking the gate as a parameter rather than consulting a
* static makes the requirement part of the signature: a caller cannot forget an argument the
* compiler insists on.
*
* @param gate the experimental consent gate
* @param flags the deployment's experimental flags
* @throws IllegalStateException naming the property that must be set
*/
public static RlsTenantSessionBinder enabledBy(
ExperimentalFeatureGate gate, java.util.Map<String, Boolean> flags) {
Objects.requireNonNull(gate, "gate")
.requireEnabled(ExperimentalFeature.MULTITENANCY_RLS, flags);
return new RlsTenantSessionBinder();
}
RlsTenantSessionBinder() {}
/** Binds {@code tenant} for the remainder of the current transaction. */
public void bind(EntityManager entityManager, TenantId tenant) {
Objects.requireNonNull(entityManager, "entityManager");
@@ -58,6 +58,7 @@ final class FileEntityMapper {
Optional.ofNullable(entity.getLeaseOwner()),
Optional.ofNullable(entity.getLeaseToken()),
Optional.ofNullable(entity.getLeaseUntil()),
!entity.isActive(),
entity.getVersion(),
entity.getCreatedAt(),
entity.getUpdatedAt());
@@ -118,6 +118,23 @@ public class JpaCleanupQueue implements CleanupQueue {
return List.copyOf(claimed);
}
@Override
public int reclaimExpiredClaims(Instant now, int limit) {
if (limit < 1) {
throw new IllegalArgumentException("limit must be positive");
}
int reclaimed = 0;
for (CleanupItemEntity abandoned : items.findExpiredClaims(now, Limit.of(limit))) {
// Matched on the token that was read, so the reaper that loses the race changes nothing —
// and the worker that eventually wakes up finds its own token gone and settles nothing.
reclaimed +=
items.reclaimExpiredClaim(
abandoned.getCleanupId(), abandoned.getClaimToken(), clock.instant());
heldClaims.remove(abandoned.getCleanupId());
}
return reclaimed;
}
@Override
public void markDone(CleanupItem item) {
Instant now = clock.instant();
@@ -28,6 +28,10 @@ import org.springframework.stereotype.Repository;
* <p>A lease is granted only when none is held or the held one expired, and an offset commit
* additionally requires the exact token plus the expected offset. This is the only correctness
* mechanism for multi-instance appends; no filesystem or NFS lock participates.
*
* <p>The session's own lifecycle is the second half of it. A lease answers who is writing now and
* says nothing about whether the upload is still one anybody may write to, so cleanup could delete
* the staged bytes of an upload a writer was about to take a lease on.
*/
@Repository
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
@@ -110,6 +114,16 @@ public class JpaUploadSessionStore implements UploadSessionStore {
leases.releaseLease(uploadId.value(), lease.token(), clock.instant());
}
@Override
public boolean terminate(UploadId uploadId) {
return leases.terminate(uploadId.value(), clock.instant()) == 1;
}
@Override
public boolean claimForCleanup(UploadId uploadId, Instant now) {
return leases.claimForCleanup(uploadId.value(), now) == 1;
}
@Override
public List<UploadSession> findExpired(Instant cutoff, int limit) {
return sessions.findExpired(cutoff, Limit.of(limit)).stream()
@@ -148,6 +148,16 @@ public class CleanupItemEntity {
return createdAt;
}
/** The token of the claim currently held, or {@code null} when the item is unclaimed. */
public UUID getClaimToken() {
return claimToken;
}
/** When the held claim stops being valid, or {@code null} when there is none. */
public Instant getLeaseUntil() {
return leaseUntil;
}
public Instant getUpdatedAt() {
return updatedAt;
}
@@ -16,6 +16,11 @@ import org.hibernate.type.SqlTypes;
* <p>The lease columns are the multi-instance single-writer mechanism. They are only ever changed
* by the conditional statements in {@code UploadLeaseRepository}, so a paused writer whose lease
* expired cannot advance {@code committed_offset}.
*
* <p>{@code lifecycle_state} is the other half of that mechanism. A lease says who is writing right
* now; it says nothing about whether the upload is still one anybody may write to, and cleanup used
* to delete staging bytes on the strength of the lease alone. A cancelled upload therefore looked
* exactly like an idle live one.
*/
@Entity
@Table(name = "fs_upload_session")
@@ -52,6 +57,9 @@ public class UploadSessionEntity {
@Column(name = "lease_until")
private Instant leaseUntil;
@Column(name = "lifecycle_state", nullable = false, length = 16)
private String lifecycleState;
@Version
@Column(name = "version", nullable = false)
private long version;
@@ -62,6 +70,12 @@ public class UploadSessionEntity {
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
/** The state of an upload that may still be written to. */
public static final String ACTIVE = "ACTIVE";
/** The state of an upload nobody may write to again. */
public static final String TERMINAL = "TERMINAL";
protected UploadSessionEntity() {}
/** Builds a fresh upload resource at offset zero and without a lease. */
@@ -78,6 +92,7 @@ public class UploadSessionEntity {
this.expectedLength = expectedLength;
this.committedOffset = 0;
this.expiresAt = expiresAt;
this.lifecycleState = ACTIVE;
this.createdAt = createdAt;
this.updatedAt = createdAt;
}
@@ -118,6 +133,15 @@ public class UploadSessionEntity {
return leaseUntil;
}
public String getLifecycleState() {
return lifecycleState;
}
/** Whether the upload may still be written to. */
public boolean isActive() {
return ACTIVE.equals(lifecycleState);
}
public long getVersion() {
return version;
}
@@ -99,4 +99,36 @@ public interface FileserverCleanupRepository extends JpaRepository<CleanupItemEn
order by c.leaseUntil
""")
List<CleanupItemEntity> findExpiredClaims(@Param("now") Instant now, Limit limit);
/**
* Returns one expired claim to the queue.
*
* <p>Matched on the token the reaper read, so two reapers racing for the same abandoned item
* cannot both hand it back — and a worker that wakes up still holding that token settles nothing,
* because {@code recordAttempt} matches on it too.
*
* <p>The item comes back as FAILED rather than PENDING, and its attempt counter advances. An item
* whose worker dies every time is then bounded by the same retry budget as one that fails
* outright, instead of being reclaimed forever.
*
* @return 1 when this caller reclaimed it, 0 when someone else already had
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update CleanupItemEntity c
set c.status = 'FAILED',
c.attempt = c.attempt + 1,
c.nextAttemptAt = :now,
c.lastErrorCode = 'CLAIM_LEASE_EXPIRED',
c.claimOwner = null,
c.claimToken = null,
c.leaseUntil = null,
c.updatedAt = :now
where c.cleanupId = :cleanupId
and c.claimToken = :token
and c.status = 'IN_PROGRESS'
""")
int reclaimExpiredClaim(
@Param("cleanupId") UUID cleanupId, @Param("token") UUID token, @Param("now") Instant now);
}
@@ -14,6 +14,11 @@ import org.springframework.data.repository.query.Param;
* <p>A lease is granted only when none is held or the held one has expired, and an offset commit
* additionally requires the exact lease token and the expected offset. Correctness never depends on
* a filesystem or NFS lock.
*
* <p>Every writer statement also requires the session to be {@code ACTIVE}. Without that clause a
* cancelled upload still handed out leases: acquire looked at the upload's expiry and the held
* lease and at no fact about the upload's own lifecycle, so a writer could take a lease on bytes
* that cleanup had already been asked to delete, and the two then raced for the same object.
*/
public interface UploadLeaseRepository extends Repository<UploadSessionEntity, UUID> {
@@ -29,6 +34,7 @@ public interface UploadLeaseRepository extends Repository<UploadSessionEntity, U
where s.uploadId = :uploadId
and s.version = :expectedVersion
and s.expiresAt > :now
and s.lifecycleState = 'ACTIVE'
and (s.leaseUntil is null or s.leaseUntil <= :now)
""")
int acquireLease(
@@ -49,6 +55,7 @@ public interface UploadLeaseRepository extends Repository<UploadSessionEntity, U
where s.uploadId = :uploadId
and s.leaseToken = :token
and s.leaseUntil > :now
and s.lifecycleState = 'ACTIVE'
""")
int renewLease(
@Param("uploadId") UUID uploadId,
@@ -66,6 +73,7 @@ public interface UploadLeaseRepository extends Repository<UploadSessionEntity, U
where s.uploadId = :uploadId
and s.leaseToken = :token
and s.leaseUntil > :now
and s.lifecycleState = 'ACTIVE'
and s.committedOffset = :expectedOffset
""")
int commitOffset(
@@ -89,4 +97,55 @@ public interface UploadLeaseRepository extends Repository<UploadSessionEntity, U
""")
int releaseLease(
@Param("uploadId") UUID uploadId, @Param("token") UUID token, @Param("now") Instant now);
/**
* Ends the upload's writable life.
*
* <p>Run inside the transaction that decides the upload is over — a cancel, a failed verification
* — so the queued staging cleanup and the fact that no writer may touch those bytes again commit
* together. Enqueuing the cleanup alone left a window in which a writer could still acquire a
* lease on the object about to be deleted.
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update UploadSessionEntity s
set s.lifecycleState = 'TERMINAL',
s.version = s.version + 1,
s.updatedAt = :now
where s.uploadId = :uploadId
and s.lifecycleState = 'ACTIVE'
""")
int terminate(@Param("uploadId") UUID uploadId, @Param("now") Instant now);
/**
* Claims a terminal session whose writer lease has lapsed, for physical deletion.
*
* <p>A claim rather than a check. Cleanup used to read the session, see no live lease and then
* delete — and a writer acquiring the lease in between turned that delete into the removal of an
* active upload's bytes. Here the database decides: the lease is cleared in the same statement
* that proves it was not held, and a writer arriving afterwards is refused by the {@code ACTIVE}
* clause on acquire.
*
* <p>Idempotent on purpose. A worker that deleted the object and died before settling runs this
* again on the retry, matches the already-cleared lease, and settles the same item once more
* rather than being stuck.
*
* @return 1 when the caller may delete the staged bytes, 0 when a writer still holds the lease or
* the session is not terminal
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update UploadSessionEntity s
set s.leaseOwner = null,
s.leaseToken = null,
s.leaseUntil = null,
s.version = s.version + 1,
s.updatedAt = :now
where s.uploadId = :uploadId
and s.lifecycleState = 'TERMINAL'
and (s.leaseUntil is null or s.leaseUntil <= :now)
""")
int claimForCleanup(@Param("uploadId") UUID uploadId, @Param("now") Instant now);
}
@@ -3,6 +3,9 @@ package dev.caskeleton.adapter.outbound.persistence.notification.platform;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/*
* Top-level, not nested inside a holder class. Spring Data does not discover nested repository
@@ -25,4 +28,39 @@ public interface DeduplicationClaimJpaRepository
String category,
String dedupKey,
long windowBucket);
/**
* Claims the deduplication window, or reports that someone else holds it.
*
* <p>{@code ON CONFLICT DO NOTHING} rather than an insert that may raise — the same shape, and
* for the same reason, as {@code NotificationRequestJpaRepository.claimIdempotencyKey}. A unique
* violation leaves the PostgreSQL transaction aborted, and the loser's whole job is to then read
* the winner, which is a statement the database refuses until rollback. The previous
* insert-then-catch-then-select could only work on a database that does not abort on constraint
* violation; on PostgreSQL the recovery read was unreachable and the loser saw the follow-up
* failure rather than the winner's notification id.
*
* @return 1 when this caller claimed the window, 0 when another already had
*/
@Modifying
@Query(
value =
"INSERT INTO notification_deduplication_claim ("
+ " id, tenant_id, recipient_ref, category, dedup_key, window_bucket,"
+ " notification_id, created_at"
+ ") VALUES ("
+ " :id, :tenantId, :recipientRef, :category, :dedupKey, :windowBucket,"
+ " :notificationId, :createdAt"
+ ") ON CONFLICT (tenant_id, recipient_ref, category, dedup_key, window_bucket)"
+ " DO NOTHING",
nativeQuery = true)
int claimWindow(
@Param("id") UUID id,
@Param("tenantId") String tenantId,
@Param("recipientRef") String recipientRef,
@Param("category") String category,
@Param("dedupKey") String dedupKey,
@Param("windowBucket") long windowBucket,
@Param("notificationId") UUID notificationId,
@Param("createdAt") java.time.Instant createdAt);
}
@@ -17,6 +17,7 @@ import dev.caskeleton.application.notification.platform.callback.DeliveryAttempt
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
import dev.caskeleton.application.notification.platform.callback.DeliveryProjection;
import dev.caskeleton.application.notification.platform.callback.DeliveryProjectionStorePort;
import dev.caskeleton.application.notification.platform.callback.ProviderRequestIdHash;
import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptRecord;
import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort;
import dev.caskeleton.application.notification.platform.dispatch.ProviderRequestIdHasherPort;
@@ -135,6 +136,19 @@ public final class JpaDeliveryAttemptStore
.flatMap(this::toSnapshot);
}
@Override
public Optional<DeliveryAttemptSnapshot> byProviderRequestIdHash(
ProviderProfileId profileId, ProviderRequestIdHash providerRequestIdHash) {
Objects.requireNonNull(profileId, "profileId");
Objects.requireNonNull(providerRequestIdHash, "providerRequestIdHash");
// The same index, reached from the side that already has the digest. A stored event never has
// the raw identifier to hash, so the overload above cannot serve a sweep.
return attempts
.findByProviderProfileIdAndProviderRequestIdHash(
profileId.value(), providerRequestIdHash.value())
.flatMap(this::toSnapshot);
}
@Override
public DeliveryProjection load(DeliveryAttemptId attemptId) {
// Every fact comes back from the row. This used to read the outcome columns and then hand back
@@ -18,7 +18,6 @@ import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.dao.DataIntegrityViolationException;
/** Preference, consent and deduplication persistence. */
public final class JpaPolicyStores {
@@ -166,32 +165,43 @@ public final class JpaPolicyStores {
// Insert first and let the unique constraint decide. A read-then-write would let two
// concurrent submissions both conclude they are the first.
try {
claims.saveAndFlush(
new DeduplicationClaimEntity(
ids.nextId(),
recipient.tenantId().value(),
recipient.recipientRef(),
recipient.category(),
dedupKey,
windowBucket,
candidate.value(),
clock.instant()));
//
// `ON CONFLICT DO NOTHING`, not insert-and-catch. The catch branch read the winner in the
// same
// transaction the unique violation had just aborted, so on PostgreSQL the loser never reached
// it: the recovery SELECT is refused until rollback, and the caller saw that refusal instead
// of the winner's notification id. The request-key claim in this same package was rewritten
// for exactly this reason and says so; this one was left behind.
int claimed =
claims.claimWindow(
ids.nextId(),
recipient.tenantId().value(),
recipient.recipientRef(),
recipient.category(),
dedupKey,
windowBucket,
candidate.value(),
clock.instant());
if (claimed == 1) {
return DeduplicationResult.first(candidate);
} catch (DataIntegrityViolationException alreadyClaimed) {
return claims
.findByTenantIdAndRecipientRefAndCategoryAndDedupKeyAndWindowBucket(
recipient.tenantId().value(),
recipient.recipientRef(),
recipient.category(),
dedupKey,
windowBucket)
.map(
existing ->
DeduplicationResult.duplicateOf(
candidate, new NotificationId(existing.notificationId())))
.orElseGet(() -> DeduplicationResult.first(candidate));
}
// Zero rows means somebody else holds the window, and the transaction is still usable, so the
// winner can actually be read.
return claims
.findByTenantIdAndRecipientRefAndCategoryAndDedupKeyAndWindowBucket(
recipient.tenantId().value(),
recipient.recipientRef(),
recipient.category(),
dedupKey,
windowBucket)
.map(
existing ->
DeduplicationResult.duplicateOf(
candidate, new NotificationId(existing.notificationId())))
// The row was claimed and is already gone — a window that expired between the two
// statements. Treating this caller as first is the safe reading: it will be deduplicated
// by the next window if the duplicate is real.
.orElseGet(() -> DeduplicationResult.first(candidate));
}
}
}
@@ -12,6 +12,7 @@ import dev.caskeleton.application.notification.platform.callback.ProviderEventLe
import dev.caskeleton.application.notification.platform.callback.ProviderEventRecord;
import dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId;
import dev.caskeleton.application.notification.platform.callback.ProviderEventSource;
import dev.caskeleton.application.notification.platform.callback.ProviderRequestIdHash;
import dev.caskeleton.application.notification.platform.callback.VerifiedProviderEvent;
import dev.caskeleton.application.notification.platform.dispatch.NotificationIdGeneratorPort;
import dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort;
@@ -207,6 +208,15 @@ public final class JpaProviderEventLedger implements ProviderEventLedger {
return List.copyOf(bound);
}
@Override
public boolean bindAttempt(ProviderEventRecordId eventId, DeliveryAttemptId attemptId) {
Objects.requireNonNull(eventId, "eventId");
Objects.requireNonNull(attemptId, "attemptId");
// The same compare-and-set the dispatch-side bind uses: whoever matched the event first keeps
// it, and a later sweep neither rebinds it nor reports having done so.
return events.bindAttemptIfUnbound(eventId.value(), attemptId.value()) == 1;
}
@Override
public List<ProviderEventRecord> eventsForAttempt(DeliveryAttemptId attemptId) {
Objects.requireNonNull(attemptId, "attemptId");
@@ -287,6 +297,10 @@ public final class JpaProviderEventLedger implements ProviderEventLedger {
Optional.ofNullable(entity.providerOccurredAt()),
Map.copyOf(decoded)),
Optional.ofNullable(entity.attemptId()).map(DeliveryAttemptId::new),
// The hash goes back out even though the raw id does not. It is the only thing a stored
// event has to find its attempt with, and dropping it left a callback that arrived before
// its attempt unmatchable by every later sweep.
Optional.ofNullable(entity.providerRequestIdHash()).map(ProviderRequestIdHash::new),
ProviderEventSource.valueOf(entity.eventSource()),
entity.signatureVerified(),
entity.receivedAt(),
@@ -4,6 +4,7 @@ import dev.caskeleton.application.notification.platform.api.RecipientDeliveryId;
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryRecord;
import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort;
import dev.caskeleton.application.notification.platform.dispatch.RecipientLease;
import java.time.Clock;
import java.time.Instant;
import java.util.Objects;
@@ -63,4 +64,67 @@ public final class JpaRecipientDeliveryStore implements RecipientDeliveryStorePo
entity.transition(state.name(), nextDispatchAt.orElse(null), clock.instant());
return mapper.toRecord(recipients.saveAndFlush(entity));
}
@Override
public Optional<RecipientDeliveryRecord> saveHeldBy(
RecipientDeliveryRecord record, RecipientLease lease) {
Objects.requireNonNull(record, "record");
Objects.requireNonNull(lease, "lease");
// One conditional statement rather than findById → mutate → saveAndFlush. The read-modify-write
// cannot express "only if I still hold this": by the time the entity is loaded the lease may
// already belong to somebody else, and JPA's version column detects a concurrent edit rather
// than a superseded writer.
int written =
recipients.saveProjectionHeldBy(
record.id().value(),
lease.owner(),
lease.fence(),
record.state().name(),
record.submissionOutcome().name(),
record.deliveryOutcome().name(),
record.evidenceLevel().name(),
record.ambiguousAttemptExists(),
record.duplicateRisk(),
record.routeCursor(),
record.attemptCount(),
record.lastFailureCategory().orElse(null),
record.nextDispatchAt().orElse(null),
clock.instant());
return reread(written, record.id());
}
@Override
public Optional<RecipientDeliveryRecord> transitionHeldBy(
RecipientDeliveryId id,
RecipientDeliveryState state,
Optional<Instant> nextDispatchAt,
RecipientLease lease) {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(state, "state");
Objects.requireNonNull(nextDispatchAt, "nextDispatchAt");
Objects.requireNonNull(lease, "lease");
int written =
recipients.transitionHeldBy(
id.value(),
lease.owner(),
lease.fence(),
state.name(),
nextDispatchAt.orElse(null),
clock.instant());
return reread(written, id);
}
/**
* Reads the row back after a conditional write, or reports that the lease was superseded.
*
* <p>The statements carry {@code clearAutomatically}, because a native update bypasses the
* persistence context and this re-read would otherwise be served from the first-level cache with
* the values the update just replaced.
*/
private Optional<RecipientDeliveryRecord> reread(int written, RecipientDeliveryId id) {
if (written == 0) {
return Optional.empty();
}
return recipients.findById(id.value()).map(mapper::toRecord);
}
}
@@ -53,6 +53,65 @@ public interface RecipientDeliveryJpaRepository
@Query(value = RecipientClaimSql.EXPIRE_OVERDUE, nativeQuery = true)
int expireOverdue(@Param("now") Instant now, @Param("batchSize") int batchSize);
/**
* Writes a completion projection, and only for the holder that still owns the job.
*
* <p>The counterpart of {@link #renewLease}, for the write that happens *after* the provider
* call. Everything before the submission is database work a new holder would simply redo; the
* outcome is not — writing it under a superseded lease reports one worker's result on another
* worker's attempt, and the two need not agree about whether the notification was sent.
*
* <p>Conditioned on owner and fence for the same reason as the renew: two incarnations of one
* configured worker id share the owner string, so the fence is what distinguishes them.
*/
// clearAutomatically, because the caller re-reads this row immediately. A native update bypasses
// the persistence context, so without it the re-read is served from the first-level cache with
// the values this statement just replaced.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
value =
"UPDATE notification_recipient_delivery "
+ "SET delivery_state = :deliveryState, submission_outcome = :submissionOutcome, "
+ "delivery_outcome = :deliveryOutcome, evidence_level = :evidenceLevel, "
+ "ambiguous_attempt_exists = :ambiguousAttemptExists, duplicate_risk = :duplicateRisk, "
+ "route_cursor = :routeCursor, attempt_count = :attemptCount, "
+ "last_failure_category = :lastFailureCategory, next_dispatch_at = :nextDispatchAt, "
+ "version = version + 1, updated_at = :now "
+ "WHERE id = :id AND lease_owner = :owner AND lease_fence = :fence",
nativeQuery = true)
int saveProjectionHeldBy(
@Param("id") UUID id,
@Param("owner") String owner,
@Param("fence") long fence,
@Param("deliveryState") String deliveryState,
@Param("submissionOutcome") String submissionOutcome,
@Param("deliveryOutcome") String deliveryOutcome,
@Param("evidenceLevel") String evidenceLevel,
@Param("ambiguousAttemptExists") boolean ambiguousAttemptExists,
@Param("duplicateRisk") boolean duplicateRisk,
@Param("routeCursor") int routeCursor,
@Param("attemptCount") int attemptCount,
@Param("lastFailureCategory") String lastFailureCategory,
@Param("nextDispatchAt") Instant nextDispatchAt,
@Param("now") Instant now);
/** Moves a job to a state, and only for the holder that still owns it. */
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
value =
"UPDATE notification_recipient_delivery "
+ "SET delivery_state = :deliveryState, next_dispatch_at = :nextDispatchAt, "
+ "version = version + 1, updated_at = :now "
+ "WHERE id = :id AND lease_owner = :owner AND lease_fence = :fence",
nativeQuery = true)
int transitionHeldBy(
@Param("id") UUID id,
@Param("owner") String owner,
@Param("fence") long fence,
@Param("deliveryState") String deliveryState,
@Param("nextDispatchAt") Instant nextDispatchAt,
@Param("now") Instant now);
/**
* Extends a lease, and only for the holder that still owns it.
*
@@ -1,12 +1,17 @@
package dev.caskeleton.adapter.outbound.persistence.postgresql;
import dev.caskeleton.adapter.outbound.persistence.api.error.VendorFailureTranslator;
import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig;
import dev.caskeleton.adapter.outbound.persistence.config.PersistenceVendorSettings;
import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping;
import dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository;
import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository;
import dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintCatalog;
import dev.caskeleton.adapter.outbound.persistence.postgresql.error.ConstraintCatalog;
import dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlExceptionTranslator;
import dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer;
import jakarta.persistence.EntityManager;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.flyway.autoconfigure.FlywayConfigurationCustomizer;
import org.springframework.context.annotation.Bean;
@@ -58,6 +63,31 @@ public class PostgreSqlPersistenceConfig {
return new PostgreSqlIdempotencyClaimRepository(entityManager);
}
/**
* The SQLSTATE translator the transaction executor classifies attempt failures with.
*
* <p>Nothing registered one, so every composition got {@code
* PersistenceFailureTranslatorChain.withoutCatalogs()} — a chain whose vendor stage returns the
* failure unchanged. A serialization failure (40001) or a deadlock (40P01) therefore left an
* attempt as a raw Spring {@code DataAccessException}, missed the retry coordinator's {@code
* JpaPersistenceException} catch, and was never retried. The unit fixtures were green because
* they threw an already-classified exception.
*
* <p>The catalog is taken from whatever the application registered and falls back to the empty
* one. An empty catalog still classifies the SQLSTATE — which is what retry depends on — and
* resolves constraint names to the unknown code, which is the correct answer for a constraint the
* application never registered.
*
* @param catalogs the application's registered constraint catalog, if it has one
* @return the vendor translator
*/
@Bean
public VendorFailureTranslator postgreSqlVendorFailureTranslator(
ObjectProvider<ConstraintCatalog> catalogs) {
return PostgreSqlExceptionTranslator.with(
catalogs.getIfAvailable(PostgreSqlConstraintCatalog::empty));
}
/**
* The vendor's migration location, as a default rather than as an override.
*
@@ -2,6 +2,7 @@ package dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency;
import java.util.Objects;
import javax.sql.DataSource;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@@ -21,10 +22,15 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
*/
final class IdempotencyCapabilityGuard {
/** The product name the PostgreSQL JDBC driver reports. */
private static final String POSTGRESQL_PRODUCT_NAME = "PostgreSQL";
private final JdbcOperations jdbc;
private final DataSource dataSource;
private final String activeCapabilitySql;
private volatile boolean postgreSqlConfirmed;
IdempotencyCapabilityGuard(
JdbcOperations jdbc, DataSource dataSource, String activeCapabilitySql) {
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
@@ -33,11 +39,21 @@ final class IdempotencyCapabilityGuard {
}
/**
* Fails closed unless the capability's schema stream is applied and promoted.
* Fails closed unless the database is PostgreSQL and the capability's schema stream is applied
* and promoted.
*
* @throws IllegalStateException naming the capability and the revision it must reach
* <p>Both, and in that order. The store's statements use {@code on conflict ... do nothing},
* {@code clock_timestamp()} and {@code for update} — an H2 or MySQL deployment that selected this
* provider would get a syntax or semantics failure somewhere inside a mutation rather than a
* refusal before it, and the vendor is the cheaper question to answer. Neither can be settled
* when the bean is built: the pool outlives any startup probe, and the schema can be promoted
* after the application starts.
*
* @throws IllegalStateException naming the vendor found, or the capability and the revision it
* must reach
*/
void requireActiveCapability() {
requirePostgreSql();
Integer active = jdbc.queryForObject(activeCapabilitySql, Integer.class);
if (active == null || active != 1) {
throw new IllegalStateException(
@@ -45,6 +61,29 @@ final class IdempotencyCapabilityGuard {
}
}
/**
* Fails closed on any vendor but PostgreSQL.
*
* <p>Answered once. The product name cannot change under a live pool, and asking the driver for
* connection metadata on every claim would put a round trip in front of the statement the claim
* is actually made of.
*/
private void requirePostgreSql() {
if (postgreSqlConfirmed) {
return;
}
String product =
jdbc.execute(
(ConnectionCallback<String>)
connection -> connection.getMetaData().getDatabaseProductName());
if (!POSTGRESQL_PRODUCT_NAME.equalsIgnoreCase(product)) {
throw new IllegalStateException(
"the owner-safe idempotency store requires PostgreSQL; this data source reports "
+ product);
}
postgreSqlConfirmed = true;
}
/**
* Fails closed unless this store's own data source is enlisted in a read-write transaction.
*
@@ -0,0 +1,175 @@
package dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency;
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimRequest;
import java.util.Objects;
import java.util.UUID;
import org.springframework.jdbc.core.JdbcOperations;
/**
* The three statements that decide who owns a scope.
*
* <p>Claiming is a different problem from advancing a claim already held. These three write the
* owner tuple itself — first insert, take-over, and the abandonment of an execution whose lease ran
* out — and every one of them is a race between processes. The transitions in {@link
* IdempotencyTransitionGateway} are races only against the owner's own retries.
*
* <p>The insert is {@code on conflict ... do nothing}, so a concurrent second claimer gets zero
* rows back rather than an exception. That matters: a unique-violation would abort the caller's
* transaction, and the loser has to be able to read the winner's row in the same transaction to
* find out what happened.
*/
final class IdempotencyClaimGateway {
/**
* The principal column's value for every V2 row.
*
* <p>V2 identity is the scope digest. The legacy V1 columns still exist on the shared table, and
* a sentinel makes it obvious that a V2 row's principal is not a principal rather than leaving a
* column that looks like one and is not.
*/
private static final String V2_PRINCIPAL_SENTINEL = "__v2_scope_digest__";
private static final String INSERT_CLAIM_SQL =
"""
insert into idempotency_record (
id, tenant, principal, idempotency_key, use_case_name,
request_hash, status, response_payload, response_ref, created_at, expires_at,
scope_hash, key_digest_version, operation_code, record_version, state_revision,
owner_token, attempt, claim_operation_id, processing_lease_until, replay_until,
policy_revision, response_codec_id, response_codec_version, response_digest, updated_at
)
select
?, '', ?, ?, ?,
?, 'CLAIMED', null, null, db_now,
db_now + (? * interval '1 millisecond'),
?, ?, ?, 2, 0,
?, 1, ?, db_now + (? * interval '1 millisecond'), null,
?, ?, 1, null, db_now
from (select clock_timestamp() as db_now) authority
on conflict (scope_hash) where record_version = 2 do nothing
""";
private static final String RESET_CLAIM_SQL =
"""
update idempotency_record
set idempotency_key = ?,
use_case_name = ?,
key_digest_version = ?,
operation_code = ?,
request_hash = ?,
status = 'CLAIMED',
state_revision = state_revision + 1,
owner_token = ?,
attempt = attempt + 1,
claim_operation_id = ?,
last_transition_operation_id = null,
last_transition_kind = null,
last_transition_result_digest = null,
reconciliation_evidence_digest = null,
processing_lease_until = clock_timestamp() + (? * interval '1 millisecond'),
replay_until = null,
policy_revision = ?,
response_codec_id = ?,
response_codec_version = 1,
response_digest = null,
response_payload = null,
response_ref = null,
failure_disposition = null,
updated_at = clock_timestamp(),
completed_at = null,
expires_at = clock_timestamp() + (? * interval '1 millisecond')
where scope_hash = ?
and record_version = 2
and status = ?
and state_revision = ?
""";
private static final String ABANDON_EXPIRED_EXECUTION_SQL =
"""
update idempotency_record
set status = 'ABANDONED',
state_revision = state_revision + 1,
last_transition_operation_id = claim_operation_id,
last_transition_kind = 'EXPIRED_EXECUTION',
last_transition_result_digest = ?,
failure_disposition = 'EFFECT_UNKNOWN_ABANDONED',
updated_at = clock_timestamp()
where scope_hash = ?
and record_version = 2
and status = 'EXECUTING'
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
""";
private final JdbcOperations jdbc;
IdempotencyClaimGateway(JdbcOperations jdbc) {
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
}
/**
* Inserts a first claim.
*
* @return {@code 1} when this caller created the row, {@code 0} when one already existed
*/
int insert(IdempotencyClaimRequest request) {
return jdbc.update(
INSERT_CLAIM_SQL,
UUID.randomUUID(),
V2_PRINCIPAL_SENTINEL,
request.scope().digest(),
request.scope().operationCode(),
request.requestFingerprint().hex(),
request.replayTtl().toMillis(),
request.scope().digest(),
request.scope().keyDigestVersion(),
request.scope().operationCode(),
request.claimAttempt().ownerToken(),
request.claimAttempt().operationId().value(),
request.processingLeaseTtl().toMillis(),
request.policyRevision(),
request.responseCodecId());
}
/**
* Takes a reclaimable row over, repeating the state it was read in.
*
* @return {@code 1} when the row was still in the state the caller read, {@code 0} otherwise
*/
int reset(IdempotencyClaimRequest request, IdempotencyRecordRow row) {
return jdbc.update(
RESET_CLAIM_SQL,
request.scope().digest(),
request.scope().operationCode(),
request.scope().keyDigestVersion(),
request.scope().operationCode(),
request.requestFingerprint().hex(),
request.claimAttempt().ownerToken(),
request.claimAttempt().operationId().value(),
request.processingLeaseTtl().toMillis(),
request.policyRevision(),
request.responseCodecId(),
request.replayTtl().toMillis(),
request.scope().digest(),
row.state().name(),
row.stateRevision());
}
/**
* Abandons an execution whose lease expired, repeating the whole owner tuple.
*
* @return {@code 1} when the expired owner was still the recorded one, {@code 0} otherwise
*/
int abandonExpiredExecution(IdempotencyRecordRow row, String resultDigest) {
return jdbc.update(
ABANDON_EXPIRED_EXECUTION_SQL,
resultDigest,
row.scopeHash(),
row.ownerToken(),
row.attempt(),
row.claimOperationId(),
row.stateRevision());
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency;
import dev.caskeleton.application.idempotency.v2.IdempotencyState;
import java.time.Instant;
/**
* One owner-safe idempotency row, as the database holds it.
*
* <p>Package-private, and deliberately not part of the port's vocabulary: the application sees
* claim and transition outcomes, never a row. Keeping the row a type of its own is what lets the
* reader, the two gateways and the store share one definition of what a row contains instead of
* each re-deriving it from the columns it happens to select.
*
* @param stateRevision the counter every owner CAS repeats; a transition that does not carry the
* revision it read is a transition that can be applied twice
*/
record IdempotencyRecordRow(
String scopeHash,
int keyDigestVersion,
String operationCode,
String requestHash,
IdempotencyState state,
long stateRevision,
String ownerToken,
long attempt,
String claimOperationId,
String lastTransitionOperationId,
String lastTransitionKind,
String lastTransitionResultDigest,
Instant processingLeaseUntil,
Instant replayUntil,
String responsePayload,
String responseDigest,
String responseCodecId,
int policyRevision,
Instant expiresAt) {}
@@ -0,0 +1,114 @@
package dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency;
import dev.caskeleton.application.idempotency.v2.IdempotencyState;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.jdbc.core.JdbcOperations;
/**
* Reads owner-safe idempotency rows, and the database's own clock.
*
* <p>Split out because reading has nothing to say about the state machine. The store decides which
* transition a row permits; this decides what a row is, which columns carry it, and how a timestamp
* crosses the JDBC boundary. Editing one transition's SQL used to mean editing the same file as the
* column list every other transition reads back.
*
* <p>The time comes from {@code clock_timestamp()} and is read <em>after</em> the row is locked. A
* lease comparison against the application's clock decides expiry from a machine that is not the
* one that wrote the lease, and reading the database's clock before taking the lock decides it from
* a moment before the row could have changed.
*/
final class IdempotencyRowMapper {
private static final String SELECT_ROW_SQL =
"""
select scope_hash, key_digest_version, operation_code, request_hash, status,
state_revision, owner_token, attempt, claim_operation_id,
last_transition_operation_id, last_transition_kind,
last_transition_result_digest, processing_lease_until, replay_until,
response_payload, response_digest, response_codec_id, policy_revision, expires_at
from idempotency_record
where scope_hash = ?
and record_version = 2
""";
private static final String SELECT_ROW_FOR_UPDATE_SQL = SELECT_ROW_SQL + " for update";
private static final String DB_NOW_SQL = "select clock_timestamp()";
private final JdbcOperations jdbc;
IdempotencyRowMapper(JdbcOperations jdbc) {
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
}
/** The row for a scope digest, without taking a lock. */
Optional<IdempotencyRecordRow> find(String scopeHash) {
return queryOne(SELECT_ROW_SQL, scopeHash);
}
/** The row for a scope digest, locked for the rest of the transaction. */
Optional<IdempotencyRecordRow> findForUpdate(String scopeHash) {
return queryOne(SELECT_ROW_FOR_UPDATE_SQL, scopeHash);
}
/**
* The database's own current time.
*
* @throws IllegalStateException when the server returned no time at all, which would otherwise
* become a null lease comparison and a silently expired claim
*/
Instant databaseNow() {
OffsetDateTime value = jdbc.queryForObject(DB_NOW_SQL, OffsetDateTime.class);
if (value == null) {
throw new IllegalStateException("PostgreSQL returned no authoritative database time");
}
return value.toInstant();
}
private Optional<IdempotencyRecordRow> queryOne(String sql, String scopeHash) {
List<IdempotencyRecordRow> rows = jdbc.query(sql, IdempotencyRowMapper::mapRow, scopeHash);
if (rows.size() > 1) {
throw new IllegalStateException("multiple idempotency V2 rows for one scope digest");
}
return rows.stream().findFirst();
}
private static IdempotencyRecordRow mapRow(ResultSet resultSet, int rowNumber)
throws SQLException {
return new IdempotencyRecordRow(
resultSet.getString("scope_hash"),
resultSet.getInt("key_digest_version"),
resultSet.getString("operation_code"),
resultSet.getString("request_hash"),
IdempotencyState.valueOf(resultSet.getString("status")),
resultSet.getLong("state_revision"),
resultSet.getString("owner_token"),
resultSet.getLong("attempt"),
resultSet.getString("claim_operation_id"),
resultSet.getString("last_transition_operation_id"),
resultSet.getString("last_transition_kind"),
resultSet.getString("last_transition_result_digest"),
instant(resultSet, "processing_lease_until"),
nullableInstant(resultSet, "replay_until"),
resultSet.getString("response_payload"),
resultSet.getString("response_digest"),
resultSet.getString("response_codec_id"),
resultSet.getInt("policy_revision"),
instant(resultSet, "expires_at"));
}
private static Instant instant(ResultSet resultSet, String column) throws SQLException {
return resultSet.getObject(column, OffsetDateTime.class).toInstant();
}
private static Instant nullableInstant(ResultSet resultSet, String column) throws SQLException {
OffsetDateTime value = resultSet.getObject(column, OffsetDateTime.class);
return value == null ? null : value.toInstant();
}
}
@@ -0,0 +1,222 @@
package dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency;
import dev.caskeleton.application.idempotency.v2.IdempotencyOwner;
import java.time.Duration;
import java.util.Objects;
import org.springframework.jdbc.core.JdbcOperations;
/**
* The statements that advance a claim its owner already holds.
*
* <p>Every one repeats the complete owner tuple — scope, token, attempt, claim operation and state
* revision — in its {@code where} clause, so the update count <em>is</em> the answer: one row means
* this owner was still the owner at this revision, zero means something else moved the record and
* the caller must not treat its own view as current. Reading the row and then updating on the scope
* alone would let a worker whose lease expired overwrite the state of the one that took over.
*
* <p>Collected here rather than in the store because they are one family: same guard, same
* interpretation of the count, same reason a caller may not skip the guard. The store decides which
* of them a given outcome permits.
*/
final class IdempotencyTransitionGateway {
private static final String START_SQL =
"""
update idempotency_record
set status = 'EXECUTING',
state_revision = state_revision + 1,
last_transition_operation_id = ?,
last_transition_kind = 'START',
last_transition_result_digest = ?,
updated_at = clock_timestamp()
where scope_hash = ?
and record_version = 2
and status = 'CLAIMED'
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
and processing_lease_until > clock_timestamp()
""";
private static final String RENEW_SQL =
"""
update idempotency_record
set state_revision = state_revision + 1,
processing_lease_until = clock_timestamp() + (? * interval '1 millisecond'),
last_transition_operation_id = ?,
last_transition_kind = 'RENEW',
last_transition_result_digest = ?,
updated_at = clock_timestamp()
where scope_hash = ?
and record_version = 2
and status in ('CLAIMED', 'EXECUTING')
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
and processing_lease_until > clock_timestamp()
""";
private static final String COMPLETE_SQL =
"""
update idempotency_record
set status = 'COMPLETED',
state_revision = state_revision + 1,
last_transition_operation_id = ?,
last_transition_kind = 'COMPLETE',
last_transition_result_digest = ?,
response_payload = ?,
response_ref = null,
response_digest = ?,
replay_until = clock_timestamp() + (? * interval '1 millisecond'),
completed_at = clock_timestamp(),
updated_at = clock_timestamp(),
expires_at = clock_timestamp() + (? * interval '1 millisecond')
where scope_hash = ?
and record_version = 2
and status = 'EXECUTING'
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
""";
private static final String FAIL_SQL =
"""
update idempotency_record
set status = ?,
state_revision = state_revision + 1,
last_transition_operation_id = ?,
last_transition_kind = ?,
last_transition_result_digest = ?,
failure_disposition = ?,
processing_lease_until = clock_timestamp(),
updated_at = clock_timestamp(),
expires_at = clock_timestamp() + (? * interval '1 millisecond')
where scope_hash = ?
and record_version = 2
and status = 'EXECUTING'
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
""";
private static final String RELEASE_SQL =
"""
update idempotency_record
set status = 'FAILED_RETRYABLE',
state_revision = state_revision + 1,
last_transition_operation_id = ?,
last_transition_kind = 'RELEASE',
last_transition_result_digest = ?,
failure_disposition = 'NO_EFFECT_RETRYABLE',
processing_lease_until = clock_timestamp(),
updated_at = clock_timestamp()
where scope_hash = ?
and record_version = 2
and status = 'CLAIMED'
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
""";
private final JdbcOperations jdbc;
IdempotencyTransitionGateway(JdbcOperations jdbc) {
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
}
/** Moves a claim into execution. */
int start(IdempotencyOwner owner, String operationId, String resultDigest) {
return jdbc.update(
START_SQL,
operationId,
resultDigest,
owner.scope().digest(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
}
/** Extends the processing lease. */
int renew(
IdempotencyOwner owner,
Duration processingLeaseTtl,
String operationId,
String resultDigest) {
return jdbc.update(
RENEW_SQL,
processingLeaseTtl.toMillis(),
operationId,
resultDigest,
owner.scope().digest(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
}
/** Records the response and opens the replay window. */
int complete(
IdempotencyOwner owner,
String operationId,
String resultDigest,
String payload,
String responseDigest,
Duration replayTtl) {
return jdbc.update(
COMPLETE_SQL,
operationId,
resultDigest,
payload,
responseDigest,
replayTtl.toMillis(),
replayTtl.toMillis(),
owner.scope().digest(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
}
/** Records a terminal failure and the disposition it carries. */
int fail(
IdempotencyOwner owner,
String targetState,
String operationId,
String transitionKind,
String resultDigest,
String disposition,
Duration retention) {
return jdbc.update(
FAIL_SQL,
targetState,
operationId,
transitionKind,
resultDigest,
disposition,
retention.toMillis(),
owner.scope().digest(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
}
/** Gives a claim back before any execution began. */
int release(IdempotencyOwner owner, String operationId, String resultDigest) {
return jdbc.update(
RELEASE_SQL,
operationId,
resultDigest,
owner.scope().digest(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
}
}
@@ -14,6 +14,7 @@ import dev.caskeleton.application.idempotency.v2.IdempotencyMutationResult;
import dev.caskeleton.application.idempotency.v2.IdempotencyOwner;
import dev.caskeleton.application.idempotency.v2.IdempotencyReleaseOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyRenewOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyScopeDigest;
import dev.caskeleton.application.idempotency.v2.IdempotencyStartOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyState;
import dev.caskeleton.application.idempotency.v2.IdempotencyStorePortV2;
@@ -22,16 +23,11 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.JdbcOperations;
/**
@@ -41,17 +37,26 @@ import org.springframework.jdbc.core.JdbcOperations;
* before {@code clock_timestamp()} is evaluated, and every state change repeats the complete owner
* CAS tuple in SQL. Raw client idempotency keys never reach this adapter.
*
* <p>Deliberately carries no Spring stereotype. Both composition roots component-scan {@code
* dev.caskeleton.adapter}, so a {@code @Repository} here was registered in every deployment
* regardless of which idempotency provider was selected: {@code provider=jdbc} acquired an
* owner-safe V2 store it never asked for, and {@code provider=redis} acquired a second one beside
* its own. Both counts are what {@code IdempotencyProviderSelectionConfig} refuses, so a scan-
* registered store meant neither selection could start.
* <p>What is left here is the port and the decisions the port's outcomes encode: which state a row
* is in, whether this caller still owns it, whether the request is a replay of a transition already
* applied, and which outcome each of those answers produces. The SQL that reads a row, the three
* statements that decide ownership, the five that advance it, the digest composition and the
* preconditions are each a collaborator of their own. That split is not cosmetic — this class held
* all of them, so changing one transition's SQL meant editing the same file as the row mapper and
* the hashing, and the transaction precondition sat next to the string constants it has nothing to
* do with.
*
* <p>{@code ca-skeleton.capabilities.idempotency.provider} is {@code disabled | jdbc | redis} and
* has no value that selects this store, so nothing composes it today; the integration test
* constructs it directly. Giving it a selector is outstanding work, and it belongs with the
* registry entry for that property rather than with a stereotype that composes it everywhere.
* <p>The collaborators are assembled in the constructor rather than injected. They are parts of
* this store, not services an application composes or replaces, and injecting them would widen the
* public bean surface from one type to six for no gain.
*
* <p>Deliberately carries no Spring stereotype. Both composition roots component-scan {@code
* dev.caskeleton.adapter}, so a {@code @Repository} here would be registered in every deployment
* regardless of which idempotency provider was selected: {@code provider=jdbc} would acquire an
* owner-safe V2 store it never asked for, and {@code provider=redis} a second one beside its own.
* Both counts are what {@code IdempotencyProviderSelectionConfig} refuses, so a scan-registered
* store means neither selection can start. The composition root builds it for {@code
* provider=postgresql} and only there.
*/
public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePortV2 {
@@ -59,8 +64,6 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
private static final Duration MAXIMUM_PROCESSING_LEASE = Duration.ofHours(1);
private static final Duration MAXIMUM_RETENTION = Duration.ofDays(30);
private static final String V2_PRINCIPAL_SENTINEL = "__v2_scope_digest__";
private static final String DB_NOW_SQL = "select clock_timestamp()";
private static final String ACTIVE_CAPABILITY_SQL =
"""
@@ -72,216 +75,23 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
and lifecycle_state = 'ACTIVE'
""";
private static final String INSERT_CLAIM_SQL =
"""
insert into idempotency_record (
id, tenant, principal, idempotency_key, use_case_name,
request_hash, status, response_payload, response_ref, created_at, expires_at,
scope_hash, key_digest_version, operation_code, record_version, state_revision,
owner_token, attempt, claim_operation_id, processing_lease_until, replay_until,
policy_revision, response_codec_id, response_codec_version, response_digest, updated_at
)
select
?, '', ?, ?, ?,
?, 'CLAIMED', null, null, db_now,
db_now + (? * interval '1 millisecond'),
?, ?, ?, 2, 0,
?, 1, ?, db_now + (? * interval '1 millisecond'), null,
?, ?, 1, null, db_now
from (select clock_timestamp() as db_now) authority
on conflict (scope_hash) where record_version = 2 do nothing
""";
private static final String SELECT_ROW_SQL =
"""
select scope_hash, key_digest_version, operation_code, request_hash, status,
state_revision, owner_token, attempt, claim_operation_id,
last_transition_operation_id, last_transition_kind,
last_transition_result_digest, processing_lease_until, replay_until,
response_payload, response_digest, response_codec_id, policy_revision, expires_at
from idempotency_record
where scope_hash = ?
and record_version = 2
""";
private static final String SELECT_ROW_FOR_UPDATE_SQL = SELECT_ROW_SQL + " for update";
private static final String RESET_CLAIM_SQL =
"""
update idempotency_record
set idempotency_key = ?,
use_case_name = ?,
key_digest_version = ?,
operation_code = ?,
request_hash = ?,
status = 'CLAIMED',
state_revision = state_revision + 1,
owner_token = ?,
attempt = attempt + 1,
claim_operation_id = ?,
last_transition_operation_id = null,
last_transition_kind = null,
last_transition_result_digest = null,
reconciliation_evidence_digest = null,
processing_lease_until = clock_timestamp() + (? * interval '1 millisecond'),
replay_until = null,
policy_revision = ?,
response_codec_id = ?,
response_codec_version = 1,
response_digest = null,
response_payload = null,
response_ref = null,
failure_disposition = null,
updated_at = clock_timestamp(),
completed_at = null,
expires_at = clock_timestamp() + (? * interval '1 millisecond')
where scope_hash = ?
and record_version = 2
and status = ?
and state_revision = ?
""";
private static final String ABANDON_EXPIRED_EXECUTION_SQL =
"""
update idempotency_record
set status = 'ABANDONED',
state_revision = state_revision + 1,
last_transition_operation_id = claim_operation_id,
last_transition_kind = 'EXPIRED_EXECUTION',
last_transition_result_digest = ?,
failure_disposition = 'EFFECT_UNKNOWN_ABANDONED',
updated_at = clock_timestamp()
where scope_hash = ?
and record_version = 2
and status = 'EXECUTING'
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
""";
private static final String START_SQL =
"""
update idempotency_record
set status = 'EXECUTING',
state_revision = state_revision + 1,
last_transition_operation_id = ?,
last_transition_kind = 'START',
last_transition_result_digest = ?,
updated_at = clock_timestamp()
where scope_hash = ?
and record_version = 2
and status = 'CLAIMED'
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
and processing_lease_until > clock_timestamp()
""";
private static final String RENEW_SQL =
"""
update idempotency_record
set state_revision = state_revision + 1,
processing_lease_until = clock_timestamp() + (? * interval '1 millisecond'),
last_transition_operation_id = ?,
last_transition_kind = 'RENEW',
last_transition_result_digest = ?,
updated_at = clock_timestamp()
where scope_hash = ?
and record_version = 2
and status in ('CLAIMED', 'EXECUTING')
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
and processing_lease_until > clock_timestamp()
""";
private static final String COMPLETE_SQL =
"""
update idempotency_record
set status = 'COMPLETED',
state_revision = state_revision + 1,
last_transition_operation_id = ?,
last_transition_kind = 'COMPLETE',
last_transition_result_digest = ?,
response_payload = ?,
response_ref = null,
response_digest = ?,
replay_until = clock_timestamp() + (? * interval '1 millisecond'),
completed_at = clock_timestamp(),
updated_at = clock_timestamp(),
expires_at = clock_timestamp() + (? * interval '1 millisecond')
where scope_hash = ?
and record_version = 2
and status = 'EXECUTING'
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
""";
private static final String FAIL_SQL =
"""
update idempotency_record
set status = ?,
state_revision = state_revision + 1,
last_transition_operation_id = ?,
last_transition_kind = ?,
last_transition_result_digest = ?,
failure_disposition = ?,
processing_lease_until = clock_timestamp(),
updated_at = clock_timestamp(),
expires_at = clock_timestamp() + (? * interval '1 millisecond')
where scope_hash = ?
and record_version = 2
and status = 'EXECUTING'
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
""";
private static final String RELEASE_SQL =
"""
update idempotency_record
set status = 'FAILED_RETRYABLE',
state_revision = state_revision + 1,
last_transition_operation_id = ?,
last_transition_kind = 'RELEASE',
last_transition_result_digest = ?,
failure_disposition = 'NO_EFFECT_RETRYABLE',
processing_lease_until = clock_timestamp(),
updated_at = clock_timestamp()
where scope_hash = ?
and record_version = 2
and status = 'CLAIMED'
and owner_token = ?
and attempt = ?
and claim_operation_id = ?
and state_revision = ?
""";
private final JdbcOperations jdbc;
private final SecureRandom secureRandom;
/**
* Schema activation and transaction preconditions, assembled here rather than injected.
*
* <p>Explicit construction keeps the public bean surface at one type. These are collaborators of
* this store, not services an application composes or replaces.
*/
private final IdempotencyCapabilityGuard guard;
private final IdempotencyRowMapper rows;
private final IdempotencyClaimGateway claims;
private final IdempotencyTransitionGateway transitions;
public PostgreSqlOwnerSafeIdempotencyStore(JdbcOperations jdbc) {
this(jdbc, new SecureRandom());
}
PostgreSqlOwnerSafeIdempotencyStore(JdbcOperations jdbc, SecureRandom secureRandom) {
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
Objects.requireNonNull(jdbc, "jdbc");
this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom");
this.guard = new IdempotencyCapabilityGuard(jdbc, dataSourceOf(jdbc), ACTIVE_CAPABILITY_SQL);
this.rows = new IdempotencyRowMapper(jdbc);
this.claims = new IdempotencyClaimGateway(jdbc);
this.transitions = new IdempotencyTransitionGateway(jdbc);
}
/**
@@ -312,26 +122,11 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
requirePrimaryWriteTransaction();
requireActiveCapability();
int inserted =
jdbc.update(
INSERT_CLAIM_SQL,
UUID.randomUUID(),
V2_PRINCIPAL_SENTINEL,
request.scope().digest(),
request.scope().operationCode(),
request.requestFingerprint().hex(),
request.replayTtl().toMillis(),
request.scope().digest(),
request.scope().keyDigestVersion(),
request.scope().operationCode(),
request.claimAttempt().ownerToken(),
request.claimAttempt().operationId().value(),
request.processingLeaseTtl().toMillis(),
request.policyRevision(),
request.responseCodecId());
int inserted = claims.insert(request);
Row row = findForUpdate(request.scope().digest()).orElseThrow(this::indeterminateClaim);
Instant dbNow = databaseNowAfterLock();
IdempotencyRecordRow row =
rows.findForUpdate(request.scope().digest()).orElseThrow(this::indeterminateClaim);
Instant dbNow = rows.databaseNow();
if (inserted == 1) {
return acquired(row);
}
@@ -379,28 +174,27 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(operationId, "operationId");
requirePrimaryWriteTransaction();
Row row = findForUpdate(owner.scope().digest()).orElse(null);
IdempotencyRecordRow row = rows.findForUpdate(owner.scope().digest()).orElse(null);
if (row == null) {
return startResult(IdempotencyStartOutcome.ABSENT, null);
}
if (isDuplicate(row, "START", operationId)) {
return startResult(IdempotencyStartOutcome.ALREADY_STARTED_SAME_OPERATION, owner(row));
String startDigest = transitionDigest("START", operationId, owner);
switch (replayVerdict(row, "START", operationId, startDigest)) {
case SAME_ARGUMENTS -> {
return startResult(IdempotencyStartOutcome.ALREADY_STARTED_SAME_OPERATION, owner(row));
}
case DIFFERENT_ARGUMENTS -> {
return startResult(IdempotencyStartOutcome.OPERATION_CONFLICT, null);
}
default -> {
// fall through to the first application
}
}
IdempotencyStartOutcome mismatch = classifyStartMismatch(row, owner);
if (mismatch != null) {
return startResult(mismatch, null);
}
String resultDigest = transitionDigest("START", operationId, owner);
int updated =
jdbc.update(
START_SQL,
operationId.value(),
resultDigest,
owner.scope().digest(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
int updated = transitions.start(owner, operationId.value(), startDigest);
if (updated != 1) {
return startResult(IdempotencyStartOutcome.NOT_OWNER, null);
}
@@ -415,28 +209,31 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
Objects.requireNonNull(operationId, "operationId");
requirePositiveBounded("processing lease TTL", processingLeaseTtl, MAXIMUM_PROCESSING_LEASE);
requirePrimaryWriteTransaction();
Row row = findForUpdate(owner.scope().digest()).orElse(null);
IdempotencyRecordRow row = rows.findForUpdate(owner.scope().digest()).orElse(null);
if (row == null) {
return renewResult(IdempotencyRenewOutcome.ABSENT, null);
}
if (isDuplicate(row, "RENEW", operationId)) {
return renewResult(IdempotencyRenewOutcome.ALREADY_RENEWED_SAME_OPERATION, owner(row));
// The lease TTL is part of what a renewal decided, so it is part of the digest. Without it, a
// retry asking for a different lease was confirmed as the renewal already applied, and the
// caller went on believing it held the record for longer than the row says it does.
String renewDigest =
transitionDigest("RENEW", operationId, owner, Long.toString(processingLeaseTtl.toMillis()));
switch (replayVerdict(row, "RENEW", operationId, renewDigest)) {
case SAME_ARGUMENTS -> {
return renewResult(IdempotencyRenewOutcome.ALREADY_RENEWED_SAME_OPERATION, owner(row));
}
case DIFFERENT_ARGUMENTS -> {
return renewResult(IdempotencyRenewOutcome.OPERATION_CONFLICT, null);
}
default -> {
// fall through to the first application
}
}
IdempotencyRenewOutcome mismatch = classifyRenewMismatch(row, owner);
if (mismatch != null) {
return renewResult(mismatch, null);
}
int updated =
jdbc.update(
RENEW_SQL,
processingLeaseTtl.toMillis(),
operationId.value(),
transitionDigest("RENEW", operationId, owner),
owner.scope().digest(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
int updated = transitions.renew(owner, processingLeaseTtl, operationId.value(), renewDigest);
if (updated != 1) {
return renewResult(IdempotencyRenewOutcome.NOT_OWNER, null);
}
@@ -456,7 +253,7 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
requirePositiveBounded("replay TTL", replayTtl, MAXIMUM_RETENTION);
requireInlineResponse(response);
requirePrimaryWriteTransaction();
Row row = findForUpdate(owner.scope().digest()).orElse(null);
IdempotencyRecordRow row = rows.findForUpdate(owner.scope().digest()).orElse(null);
if (row == null) {
return IdempotencyCompleteOutcome.ABSENT;
}
@@ -473,8 +270,8 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
return mismatch;
}
int updated =
jdbc.update(
COMPLETE_SQL,
transitions.complete(
owner,
operationId.value(),
// The transition digest, not the response digest. Reusing the response digest here made
// two completions of different operations with identical payloads indistinguishable,
@@ -487,13 +284,7 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
Long.toString(replayTtl.toMillis())),
response.payload(),
responseDigest,
replayTtl.toMillis(),
replayTtl.toMillis(),
owner.scope().digest(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
replayTtl);
return updated == 1
? IdempotencyCompleteOutcome.COMPLETED
: IdempotencyCompleteOutcome.INDETERMINATE;
@@ -510,7 +301,7 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
Objects.requireNonNull(operationId, "operationId");
requirePositiveBounded("failure retention", retention, MAXIMUM_RETENTION);
requirePrimaryWriteTransaction();
Row row = findForUpdate(owner.scope().digest()).orElse(null);
IdempotencyRecordRow row = rows.findForUpdate(owner.scope().digest()).orElse(null);
if (row == null) {
return IdempotencyFailOutcome.ABSENT;
}
@@ -518,8 +309,23 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
disposition == IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE
? "FAIL_RETRYABLE"
: "FAIL_ABANDONED";
if (isDuplicate(row, transitionKind, operationId)) {
return IdempotencyFailOutcome.ALREADY_MARKED_SAME_OPERATION;
String failDigest =
transitionDigest(
transitionKind,
operationId,
owner,
disposition.name(),
Long.toString(retention.toMillis()));
switch (replayVerdict(row, transitionKind, operationId, failDigest)) {
case SAME_ARGUMENTS -> {
return IdempotencyFailOutcome.ALREADY_MARKED_SAME_OPERATION;
}
case DIFFERENT_ARGUMENTS -> {
return IdempotencyFailOutcome.OPERATION_CONFLICT;
}
default -> {
// fall through to the first application
}
}
IdempotencyFailOutcome mismatch = classifyFailMismatch(row, owner);
if (mismatch != null) {
@@ -530,24 +336,14 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
? IdempotencyState.FAILED_RETRYABLE.name()
: IdempotencyState.ABANDONED.name();
int updated =
jdbc.update(
FAIL_SQL,
transitions.fail(
owner,
targetState,
operationId.value(),
transitionKind,
transitionDigest(
transitionKind,
operationId,
owner,
disposition.name(),
Long.toString(retention.toMillis())),
failDigest,
disposition.name(),
retention.toMillis(),
owner.scope().digest(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
retention);
if (updated != 1) {
return IdempotencyFailOutcome.INDETERMINATE;
}
@@ -562,12 +358,21 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(operationId, "operationId");
requirePrimaryWriteTransaction();
Row row = findForUpdate(owner.scope().digest()).orElse(null);
IdempotencyRecordRow row = rows.findForUpdate(owner.scope().digest()).orElse(null);
if (row == null) {
return IdempotencyReleaseOutcome.ABSENT;
}
if (isDuplicate(row, "RELEASE", operationId)) {
return IdempotencyReleaseOutcome.ALREADY_RELEASED_SAME_OPERATION;
String releaseDigest = transitionDigest("RELEASE", operationId, owner);
switch (replayVerdict(row, "RELEASE", operationId, releaseDigest)) {
case SAME_ARGUMENTS -> {
return IdempotencyReleaseOutcome.ALREADY_RELEASED_SAME_OPERATION;
}
case DIFFERENT_ARGUMENTS -> {
return IdempotencyReleaseOutcome.OPERATION_CONFLICT;
}
default -> {
// fall through to the first application
}
}
if (!sameOwnerTuple(row, owner)) {
return IdempotencyReleaseOutcome.NOT_OWNER;
@@ -578,16 +383,7 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
if (row.state() != IdempotencyState.CLAIMED) {
return IdempotencyReleaseOutcome.OPERATION_CONFLICT;
}
int updated =
jdbc.update(
RELEASE_SQL,
operationId.value(),
transitionDigest("RELEASE", operationId, owner),
owner.scope().digest(),
owner.ownerToken(),
owner.attempt(),
owner.claimOperationId().value(),
owner.stateRevision());
int updated = transitions.release(owner, operationId.value(), releaseDigest);
return updated == 1
? IdempotencyReleaseOutcome.RELEASED_BEFORE_EXECUTION
: IdempotencyReleaseOutcome.INDETERMINATE;
@@ -596,11 +392,11 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
@Override
public IdempotencyInspection inspect(IdempotencyInspectionRequest request) {
Objects.requireNonNull(request, "request");
Optional<Row> found = find(request.scope().digest());
Optional<IdempotencyRecordRow> found = rows.find(request.scope().digest());
if (found.isEmpty()) {
return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.ABSENT);
}
Row row = found.get();
IdempotencyRecordRow row = found.get();
if (!row.requestHash().equals(request.requestFingerprint().hex())) {
return IdempotencyInspection.outcome(IdempotencyInspectionOutcome.FINGERPRINT_MISMATCH);
}
@@ -630,48 +426,25 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
};
}
private IdempotencyClaimOutcome resetClaim(IdempotencyClaimRequest request, Row row) {
int updated =
jdbc.update(
RESET_CLAIM_SQL,
request.scope().digest(),
request.scope().operationCode(),
request.scope().keyDigestVersion(),
request.scope().operationCode(),
request.requestFingerprint().hex(),
request.claimAttempt().ownerToken(),
request.claimAttempt().operationId().value(),
request.processingLeaseTtl().toMillis(),
request.policyRevision(),
request.responseCodecId(),
request.replayTtl().toMillis(),
request.scope().digest(),
row.state().name(),
row.stateRevision());
if (updated != 1) {
private IdempotencyClaimOutcome resetClaim(
IdempotencyClaimRequest request, IdempotencyRecordRow row) {
if (claims.reset(request, row) != 1) {
return new IdempotencyClaimOutcome.Indeterminate(request.claimAttempt().operationId());
}
Row reset = findForUpdate(request.scope().digest()).orElseThrow(this::indeterminateClaim);
IdempotencyRecordRow reset =
rows.findForUpdate(request.scope().digest()).orElseThrow(this::indeterminateClaim);
return new IdempotencyClaimOutcome.TakenOverClaimed(owner(reset), reset.processingLeaseUntil());
}
private void abandonExpiredExecution(Row row) {
private void abandonExpiredExecution(IdempotencyRecordRow row) {
String resultDigest = sha256("EXPIRED_EXECUTION|" + row.claimOperationId());
int updated =
jdbc.update(
ABANDON_EXPIRED_EXECUTION_SQL,
resultDigest,
row.scopeHash(),
row.ownerToken(),
row.attempt(),
row.claimOperationId(),
row.stateRevision());
if (updated != 1) {
if (claims.abandonExpiredExecution(row, resultDigest) != 1) {
throw indeterminateClaim();
}
}
private IdempotencyStartOutcome classifyStartMismatch(Row row, IdempotencyOwner owner) {
private IdempotencyStartOutcome classifyStartMismatch(
IdempotencyRecordRow row, IdempotencyOwner owner) {
if (!sameOwnerIdentity(row, owner)) {
return IdempotencyStartOutcome.NOT_OWNER;
}
@@ -684,7 +457,8 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
return null;
}
private IdempotencyRenewOutcome classifyRenewMismatch(Row row, IdempotencyOwner owner) {
private IdempotencyRenewOutcome classifyRenewMismatch(
IdempotencyRecordRow row, IdempotencyOwner owner) {
if (!sameOwnerIdentity(row, owner)) {
return IdempotencyRenewOutcome.NOT_OWNER;
}
@@ -697,7 +471,8 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
return null;
}
private IdempotencyCompleteOutcome classifyCompleteMismatch(Row row, IdempotencyOwner owner) {
private IdempotencyCompleteOutcome classifyCompleteMismatch(
IdempotencyRecordRow row, IdempotencyOwner owner) {
if (!sameOwnerIdentity(row, owner)) {
return IdempotencyCompleteOutcome.NOT_OWNER;
}
@@ -710,7 +485,8 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
return null;
}
private IdempotencyFailOutcome classifyFailMismatch(Row row, IdempotencyOwner owner) {
private IdempotencyFailOutcome classifyFailMismatch(
IdempotencyRecordRow row, IdempotencyOwner owner) {
if (!sameOwnerIdentity(row, owner)) {
return IdempotencyFailOutcome.NOT_OWNER;
}
@@ -723,53 +499,6 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
return null;
}
private Optional<Row> findForUpdate(String scopeHash) {
return queryOne(SELECT_ROW_FOR_UPDATE_SQL, scopeHash);
}
private Optional<Row> find(String scopeHash) {
return queryOne(SELECT_ROW_SQL, scopeHash);
}
private Optional<Row> queryOne(String sql, String scopeHash) {
List<Row> rows = jdbc.query(sql, this::mapRow, scopeHash);
if (rows.size() > 1) {
throw new IllegalStateException("multiple idempotency V2 rows for one scope digest");
}
return rows.stream().findFirst();
}
private Row mapRow(ResultSet resultSet, int rowNumber) throws SQLException {
return new Row(
resultSet.getString("scope_hash"),
resultSet.getInt("key_digest_version"),
resultSet.getString("operation_code"),
resultSet.getString("request_hash"),
IdempotencyState.valueOf(resultSet.getString("status")),
resultSet.getLong("state_revision"),
resultSet.getString("owner_token"),
resultSet.getLong("attempt"),
resultSet.getString("claim_operation_id"),
resultSet.getString("last_transition_operation_id"),
resultSet.getString("last_transition_kind"),
resultSet.getString("last_transition_result_digest"),
instant(resultSet, "processing_lease_until"),
nullableInstant(resultSet, "replay_until"),
resultSet.getString("response_payload"),
resultSet.getString("response_digest"),
resultSet.getString("response_codec_id"),
resultSet.getInt("policy_revision"),
instant(resultSet, "expires_at"));
}
private Instant databaseNowAfterLock() {
OffsetDateTime value = jdbc.queryForObject(DB_NOW_SQL, OffsetDateTime.class);
if (value == null) {
throw new IllegalStateException("PostgreSQL returned no authoritative database time");
}
return value.toInstant();
}
private void requireActiveCapability() {
guard.requireActiveCapability();
}
@@ -793,41 +522,80 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
}
}
private static boolean sameClaimAttempt(Row row, IdempotencyClaimAttempt attempt) {
private static boolean sameClaimAttempt(
IdempotencyRecordRow row, IdempotencyClaimAttempt attempt) {
return row.ownerToken().equals(attempt.ownerToken())
&& row.claimOperationId().equals(attempt.operationId().value());
}
private static boolean sameOwnerIdentity(Row row, IdempotencyOwner owner) {
private static boolean sameOwnerIdentity(IdempotencyRecordRow row, IdempotencyOwner owner) {
return row.scopeHash().equals(owner.scope().digest())
&& row.ownerToken().equals(owner.ownerToken())
&& row.attempt() == owner.attempt()
&& row.claimOperationId().equals(owner.claimOperationId().value());
}
private static boolean sameOwnerTuple(Row row, IdempotencyOwner owner) {
private static boolean sameOwnerTuple(IdempotencyRecordRow row, IdempotencyOwner owner) {
return sameOwnerIdentity(row, owner) && row.stateRevision() == owner.stateRevision();
}
private static boolean isDuplicate(Row row, String transitionKind, OperationId operationId) {
return transitionKind.equals(row.lastTransitionKind())
&& operationId.value().equals(row.lastTransitionOperationId());
/**
* Whether the recorded transition is this call again, and whether it carried these arguments.
*
* <p>The kind and the operation id were the whole test, and the digest the transition wrote was
* read by nothing. So a second {@code markFailed} under one operation id was answered {@code
* ALREADY_MARKED_SAME_OPERATION} whatever it asked for: a retry that changed the disposition from
* retryable to abandoned, or changed the retention, was reported as the already-applied
* transition and silently did not happen. The caller's evidence then said the record was
* abandoned while the row said retryable.
*
* <p>Recomputing the digest from the caller's own arguments answers it. A genuine retry presents
* the owner handle it presented the first time, so the revision, the owner and every semantic
* argument reproduce the stored digest exactly; anything else is a different transition wearing
* the same operation id, and belongs in a conflict rather than in a confirmation.
*/
private static ReplayVerdict replayVerdict(
IdempotencyRecordRow row,
String transitionKind,
OperationId operationId,
String expectedDigest) {
if (!transitionKind.equals(row.lastTransitionKind())
|| !operationId.value().equals(row.lastTransitionOperationId())) {
return ReplayVerdict.NOT_A_REPLAY;
}
return expectedDigest.equals(row.lastTransitionResultDigest())
? ReplayVerdict.SAME_ARGUMENTS
: ReplayVerdict.DIFFERENT_ARGUMENTS;
}
private static boolean isExpiredCompleted(Row row, Instant dbNow) {
/** What a recorded transition under the caller's operation id turned out to be. */
private enum ReplayVerdict {
/** No transition of this kind is recorded, so the caller is asking for a first application. */
NOT_A_REPLAY,
/** The recorded transition is this one: confirm it rather than applying it twice. */
SAME_ARGUMENTS,
/**
* A transition of this kind is recorded under different arguments: a conflict, not a replay.
*/
DIFFERENT_ARGUMENTS
}
private static boolean isExpiredCompleted(IdempotencyRecordRow row, Instant dbNow) {
return row.state() == IdempotencyState.COMPLETED
&& row.replayUntil() != null
&& !dbNow.isBefore(row.replayUntil());
}
private static IdempotencyClaimOutcome.Acquired acquired(Row row) {
private static IdempotencyClaimOutcome.Acquired acquired(IdempotencyRecordRow row) {
return new IdempotencyClaimOutcome.Acquired(owner(row), row.processingLeaseUntil());
}
private static IdempotencyOwner owner(Row row) {
private static IdempotencyOwner owner(IdempotencyRecordRow row) {
return new IdempotencyOwner(
new dev.caskeleton.application.idempotency.v2.IdempotencyScopeDigest(
row.scopeHash(), row.keyDigestVersion(), row.operationCode()),
new IdempotencyScopeDigest(row.scopeHash(), row.keyDigestVersion(), row.operationCode()),
row.ownerToken(),
row.attempt(),
row.stateRevision(),
@@ -845,7 +613,7 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
}
private static IdempotencyInspection inspectionWithOwner(
IdempotencyInspectionOutcome outcome, Row row) {
IdempotencyInspectionOutcome outcome, IdempotencyRecordRow row) {
return new IdempotencyInspection(
outcome,
Optional.of(owner(row)),
@@ -888,37 +656,7 @@ public class PostgreSqlOwnerSafeIdempotencyStore implements IdempotencyStorePort
}
}
private static Instant instant(ResultSet resultSet, String column) throws SQLException {
return resultSet.getObject(column, OffsetDateTime.class).toInstant();
}
private static Instant nullableInstant(ResultSet resultSet, String column) throws SQLException {
OffsetDateTime value = resultSet.getObject(column, OffsetDateTime.class);
return value == null ? null : value.toInstant();
}
private IllegalStateException indeterminateClaim() {
return new IllegalStateException("owner-safe idempotency claim outcome is indeterminate");
}
private record Row(
String scopeHash,
int keyDigestVersion,
String operationCode,
String requestHash,
IdempotencyState state,
long stateRevision,
String ownerToken,
long attempt,
String claimOperationId,
String lastTransitionOperationId,
String lastTransitionKind,
String lastTransitionResultDigest,
Instant processingLeaseUntil,
Instant replayUntil,
String responsePayload,
String responseDigest,
String responseCodecId,
int policyRevision,
Instant expiresAt) {}
}
@@ -0,0 +1,49 @@
-- A terminal upload state, so a cancelled upload and a live one stop looking alike.
--
-- Cleanup read the writer lease, found none, and then deleted the staging bytes. Between the read
-- and the delete a writer can acquire that very lease — nothing in the database said the upload was
-- finished with — and the object cleanup removed was one an upload was actively appending to. The
-- writer's own acquire statement checked the upload's expiry and its lease and nothing about
-- whether the upload had been cancelled or had failed verification, so it granted the lease
-- happily.
--
-- The state is what both sides now agree on. Cancel and failed finalize move the session to
-- TERMINAL inside the transaction that queues the cleanup; acquire, renew and offset commit require
-- ACTIVE; and cleanup claims the row with a conditional update rather than deciding from a value it
-- read a moment earlier. A writer that arrives after the terminalize is refused, and a cleanup that
-- arrives while a lease is genuinely held claims nothing and defers.
--
-- Defaulted to ACTIVE, so every session that predates this column keeps behaving exactly as it did.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM capability_schema_registry
WHERE capability_id = 'jpa-fileserver-metadata-v1'
AND feature_revision >= 2
) THEN
RAISE EXCEPTION 'fileserver upload terminal state requires fileserver metadata revision 2';
END IF;
END
$$;
ALTER TABLE fs_upload_session
ADD COLUMN IF NOT EXISTS lifecycle_state varchar(16) NOT NULL DEFAULT 'ACTIVE';
ALTER TABLE fs_upload_session
DROP CONSTRAINT IF EXISTS ck_fs_upload_lifecycle_state;
ALTER TABLE fs_upload_session
ADD CONSTRAINT ck_fs_upload_lifecycle_state
CHECK (lifecycle_state IN ('ACTIVE', 'TERMINAL'));
COMMENT ON COLUMN fs_upload_session.lifecycle_state IS
'ACTIVE while the upload may still be written to. Cancel and failed finalize set TERMINAL in '
'the same transaction that queues the staging cleanup, and the writer statements refuse a '
'TERMINAL session, so cleanup cannot race a writer for the same bytes.';
-- Cleanup's claim: terminal sessions whose lease has lapsed.
CREATE INDEX IF NOT EXISTS ix_fs_upload_session_terminal
ON fs_upload_session (lease_until)
WHERE lifecycle_state = 'TERMINAL';
@@ -9,9 +9,12 @@ import dev.caskeleton.application.idempotency.v2.IdempotencyClaimAttempt;
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimRequest;
import dev.caskeleton.application.idempotency.v2.IdempotencyCompleteOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyFailOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyFailureDisposition;
import dev.caskeleton.application.idempotency.v2.IdempotencyInspectionOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyInspectionRequest;
import dev.caskeleton.application.idempotency.v2.IdempotencyOwner;
import dev.caskeleton.application.idempotency.v2.IdempotencyRenewOutcome;
import dev.caskeleton.application.idempotency.v2.IdempotencyScopeDigest;
import dev.caskeleton.application.idempotency.v2.IdempotencyStartOutcome;
import dev.caskeleton.application.transaction.OperationId;
@@ -249,11 +252,117 @@ class PostgreSqlIdempotencyIntegrationTest {
.isEqualTo(1);
}
@Test
void aRetriedFailureWithTheSameArgumentsIsConfirmedRatherThanAppliedTwice() {
IdempotencyOwner executing = claimAndStart("claim-fail-replay", "start-fail-replay");
IdempotencyFailOutcome first =
transactions.execute(
ignored ->
store.markFailed(
executing,
IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE,
Duration.ofHours(1),
new OperationId("fail-1")));
IdempotencyFailOutcome retry =
transactions.execute(
ignored ->
store.markFailed(
executing,
IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE,
Duration.ofHours(1),
new OperationId("fail-1")));
assertThat(first).isEqualTo(IdempotencyFailOutcome.MARKED_RETRYABLE);
assertThat(retry).isEqualTo(IdempotencyFailOutcome.ALREADY_MARKED_SAME_OPERATION);
}
@Test
void aFailureRepeatedUnderOneOperationIdWithDifferentRetentionIsAConflict() {
IdempotencyOwner executing = claimAndStart("claim-fail-conflict", "start-fail-conflict");
transactions.execute(
ignored ->
store.markFailed(
executing,
IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE,
Duration.ofHours(1),
new OperationId("fail-2")));
IdempotencyFailOutcome different =
transactions.execute(
ignored ->
store.markFailed(
executing,
IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE,
Duration.ofHours(9),
new OperationId("fail-2")));
assertThat(different)
.as(
"the recorded failure kept the record for an hour; reporting this call as the one"
+ " already applied would tell the caller it got nine")
.isEqualTo(IdempotencyFailOutcome.OPERATION_CONFLICT);
assertThat(
jdbc.queryForObject(
"select extract(epoch from (expires_at - updated_at))::bigint "
+ "from idempotency_record where scope_hash = ?",
Long.class,
SCOPE.digest()))
.isCloseTo(3600L, org.assertj.core.data.Offset.offset(5L));
}
@Test
void aRenewalRepeatedUnderOneOperationIdWithADifferentLeaseIsAConflict() {
IdempotencyOwner claimed = claim("claim-renew-conflict");
IdempotencyRenewOutcome first =
transactions
.execute(
ignored -> store.renew(claimed, Duration.ofSeconds(30), new OperationId("renew-1")))
.outcome();
IdempotencyRenewOutcome same =
transactions
.execute(
ignored -> store.renew(claimed, Duration.ofSeconds(30), new OperationId("renew-1")))
.outcome();
IdempotencyRenewOutcome different =
transactions
.execute(
ignored -> store.renew(claimed, Duration.ofMinutes(45), new OperationId("renew-1")))
.outcome();
assertThat(first).isEqualTo(IdempotencyRenewOutcome.RENEWED);
assertThat(same).isEqualTo(IdempotencyRenewOutcome.ALREADY_RENEWED_SAME_OPERATION);
assertThat(different)
.as(
"a renewal that granted thirty seconds must not answer for one asking forty-five minutes")
.isEqualTo(IdempotencyRenewOutcome.OPERATION_CONFLICT);
}
@Test
void optionalStreamLifecycleIsNonDestructiveAndRecoversInterruptedMigration() throws Exception {
PostgreSqlOptionalStreamLifecycle.verify(PostgreSqlOptionalStreamLifecycle.idempotency());
}
private static IdempotencyOwner claim(String claimOperation) {
return transactions.execute(
ignored ->
((IdempotencyClaimOutcome.Acquired)
store.claim(
request(
store.newClaimAttempt(new OperationId(claimOperation)),
Duration.ofSeconds(30))))
.owner());
}
private static IdempotencyOwner claimAndStart(String claimOperation, String startOperation) {
IdempotencyOwner claimed = claim(claimOperation);
return transactions
.execute(ignored -> store.markExecutionStarted(claimed, new OperationId(startOperation)))
.owner()
.orElseThrow();
}
private static IdempotencyClaimRequest request(
IdempotencyClaimAttempt attempt, Duration processingLease) {
return new IdempotencyClaimRequest(
@@ -0,0 +1,124 @@
package dev.caskeleton.adapter.outbound.persistence.readiness;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.UUID;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* The loser of a deduplication race has to be able to read the winner.
*
* <p>The same defect as the idempotency-key race, in the store next to it, left behind when that
* one was fixed. `DeduplicationClaims.insertOrFind` inserted and caught the unique violation, then
* read the winner — and PostgreSQL leaves a transaction aborted after a statement-level constraint
* violation, so the read is refused until rollback. The loser therefore never learned which
* notification already owned the window; it saw SQLSTATE 25P02 instead, and "the duplicate
* converges on the original" was unreachable on the one path where two callers actually collide.
*
* <p>Against a real server, because the defect is entirely what PostgreSQL does to a transaction
* after a constraint violation. No fake reproduces it: an in-memory map simply returns the existing
* entry, which is why the unit tests for deduplication were green throughout.
*/
@Tag("jpa-contract")
class PostgreSqlNotificationDedupRaceIntegrationTest {
private static final String CLAIM =
"INSERT INTO notification_deduplication_claim ("
+ " id, tenant_id, recipient_ref, category, dedup_key, window_bucket,"
+ " notification_id, created_at"
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, now())"
+ " ON CONFLICT (tenant_id, recipient_ref, category, dedup_key, window_bucket)"
+ " DO NOTHING";
@Test
@DisplayName("the losing claim reads the winner in a transaction that is still usable")
void theLoserReadsTheWinner() throws Exception {
try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) {
migrate(database);
JdbcTemplate jdbc = new JdbcTemplate(database.dataSource());
UUID winner = UUID.randomUUID();
assertThat(claim(jdbc, winner)).isEqualTo(1);
// The losing caller, in one transaction: claim, get zero rows, then read the winner. The read
// is the statement the old catch-block version could never reach.
UUID loser = UUID.randomUUID();
assertThat(claim(jdbc, loser)).isZero();
String owner =
jdbc.queryForObject(
"select notification_id::text from notification_deduplication_claim"
+ " where tenant_id = 'tenant-1' and dedup_key = 'dedup-1'",
String.class);
assertThat(owner)
.as("the loser converges on the notification that claimed the window")
.isEqualTo(winner.toString());
}
}
@Test
@DisplayName("a different window is a different claim, so the conflict target is the right one")
void aDifferentWindowIsNotADuplicate() {
// Without this, the assertion above is satisfied by a conflict target so broad that every claim
// collides — which would deduplicate notifications that are not duplicates at all.
try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) {
migrate(database);
JdbcTemplate jdbc = new JdbcTemplate(database.dataSource());
assertThat(claim(jdbc, UUID.randomUUID(), 1L)).isEqualTo(1);
assertThat(claim(jdbc, UUID.randomUUID(), 2L)).isEqualTo(1);
assertThat(
jdbc.queryForObject(
"select count(*) from notification_deduplication_claim", Integer.class))
.isEqualTo(2);
}
}
private static int claim(JdbcTemplate jdbc, UUID notificationId) {
return claim(jdbc, notificationId, 1L);
}
private static int claim(JdbcTemplate jdbc, UUID notificationId, long windowBucket) {
return jdbc.update(
CLAIM,
UUID.randomUUID(),
"tenant-1",
"recipient-1",
"transactional",
"dedup-1",
windowBucket,
notificationId);
}
private static void migrate(PostgreSqlReadinessSupport database) {
Flyway core =
Flyway.configure()
.dataSource(database.dataSource())
.locations("classpath:db/migration/jpa/core")
.table("flyway_jpa_core_history")
.baselineVersion("0")
.baselineDescription("core baseline")
.load();
core.baseline();
core.migrate();
new JdbcTemplate(database.dataSource())
.update(
"update capability_schema_registry set lifecycle_state = 'ACTIVE' "
+ "where capability_id = ?",
"jpa-flyway-migration");
Flyway notification =
Flyway.configure()
.dataSource(database.dataSource())
.locations("classpath:db/migration/jpa/notification-platform")
.table("flyway_jpa_notification_history")
.baselineVersion("0")
.baselineDescription("notification baseline")
.load();
notification.baseline();
notification.migrate();
}
}
@@ -39,7 +39,10 @@ final class PostgreSqlOptionalStreamLifecycle {
"flyway_jpa_idempotency_history",
2,
List.of("idempotency_record"),
List.of("0", "1"),
// V2 widens request_hash from char(64) to varchar. Two streams can create this table and
// their relative order is not fixed, so whichever runs second corrects the column; that
// correction is a version of this stream and belongs in its applied set.
List.of("0", "1", "2"),
List.of());
}
@@ -94,7 +97,10 @@ final class PostgreSqlOptionalStreamLifecycle {
"fs_quota_reservation",
"fs_cleanup_item",
"fs_recovery_item"),
List.of("0", "1", "2"),
// V3 fences the cleanup claim with an owner, a token and a lease expiry; V4 gives an upload
// a terminal state, so cleanup and a live writer stop competing over a row that looks
// available to both.
List.of("0", "1", "2", "3", "4"),
List.of());
}
@@ -126,7 +132,16 @@ final class PostgreSqlOptionalStreamLifecycle {
// V8 makes the admin operation id a claim rather than a check.
// V9 gives each execution-evidence fact its certainty, so a restart can still tell
// "proven not sent" from "unknown whether sent".
List.of("0", "1", "2", "3", "4", "5", "6", "7", "8", "9"),
// V10 guards the at-rest envelope: variables_payload holds a base64 AES-GCM envelope, and a
// row still carrying plaintext fails the migration rather than meeting the mapper at
// runtime,
// where it would look like a decryption bug instead of an un-migrated table.
//
// This list is the stream's contract, not a note about its length: the lifecycle below
// disables, re-enables and interrupts the stream and asserts the applied set is unchanged
// each time. So a migration added to the stream belongs here, and the version that landed
// without being added is why the lane failed the first time anybody ran it.
List.of("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"),
List.of());
}
@@ -67,6 +67,76 @@ class PostgreSqlRecipientLeaseFencingIntegrationTest {
}
}
@Test
@DisplayName("a superseded holder cannot write its outcome over its replacement's job")
void aSupersededHolderCannotWriteItsOutcome() throws Exception {
// The gap the claim CTE and the fenced renew did not cover. Everything before the provider call
// is database work a new holder would redo; the outcome is written *after* it, and that write
// used to be findById → mutate → saveAndFlush with no owner or fence. A worker whose lease
// expired during the submission came back and described an attempt that was no longer the live
// one. The @Version column does not stop it: it detects a concurrent edit, not a superseded
// writer, and this worker's read is recent enough to win.
try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) {
Fixture fixture = Fixture.migrated(database);
UUID job = fixture.insertDueJob();
RecipientLease held = fixture.store("worker-a").claim("worker-a", 10, LEASE).getFirst();
fixture.expireLease(job);
RecipientLease replacement =
fixture.store("worker-b").claim("worker-b", 10, LEASE).getFirst();
int written =
fixture.inTransaction(
() ->
fixture
.repository()
.transitionHeldBy(
job,
held.owner(),
held.fence(),
"COMPLETED",
null,
java.time.Instant.now()));
assertThat(written).as("the superseded worker's completion must match no row").isZero();
Map<String, Object> row = fixture.readLease(job);
assertThat(row.get("lease_owner")).isEqualTo("worker-b");
assertThat(((Number) row.get("lease_fence")).longValue()).isEqualTo(replacement.fence());
assertThat(row.get("delivery_state"))
.as("the live holder's job must not have been completed by the worker that lost it")
.isNotEqualTo("COMPLETED");
}
}
@Test
@DisplayName("the holder that still owns the job does write its outcome")
void theCurrentHolderWritesItsOutcome() throws Exception {
// Without this, the assertion above is satisfied by a statement that matches nothing ever.
try (PostgreSqlReadinessSupport database = PostgreSqlReadinessSupport.start()) {
Fixture fixture = Fixture.migrated(database);
UUID job = fixture.insertDueJob();
RecipientLease held = fixture.store("worker-a").claim("worker-a", 10, LEASE).getFirst();
int written =
fixture.inTransaction(
() ->
fixture
.repository()
.transitionHeldBy(
job,
held.owner(),
held.fence(),
"COMPLETED",
null,
java.time.Instant.now()));
assertThat(written).isEqualTo(1);
assertThat(fixture.readLease(job).get("delivery_state")).isEqualTo("COMPLETED");
}
}
@Test
@DisplayName("a superseded holder cannot release its replacement's lease")
void aSupersededHolderCannotReleaseItsReplacementsLease() throws Exception {
@@ -0,0 +1,85 @@
package dev.caskeleton.adapter.outbound.persistence.auditing;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.persistence.audit.AuditableEntity;
import jakarta.persistence.Column;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
/**
* The second audit mechanism stays a candidate, and stays distinguishable from the first.
*
* <p>Two complete technical-audit mechanisms live in this leaf. They disagree on column names, on
* actor length and on when the stamp is captured, and the canonical one is {@link AuditableEntity}
* — the base the sample entities extend and the migrations were written for. The other is
* implemented, tested and composed by nothing.
*
* <p>Two things can go wrong quietly. The candidate can acquire a stereotype and start stamping in
* every deployment that has this module on the classpath, including the ones whose tables have no
* such columns — where the result is a startup failure rather than a feature. Or somebody can
* "harmonise" the two by editing one side's column names, at which point the schema a deployed
* table was migrated for and the schema the entity expects diverge with no migration in between.
* Both are silent, so both are asserted.
*/
class AuditingCandidateStatusTest {
private static final List<Class<? extends Annotation>> COMPOSING_STEREOTYPES =
List.of(
Configuration.class, Component.class, org.springframework.stereotype.Repository.class);
@Test
@DisplayName("the candidate wiring carries no stereotype, so a scan cannot compose it")
void theCandidateWiringCarriesNoStereotype() {
for (Class<? extends Annotation> stereotype : COMPOSING_STEREOTYPES) {
assertThat(JpaAuditingConfiguration.class.getAnnotation(stereotype))
.as(
"@%s here would stamp audit columns in every deployment that merely has this module",
stereotype.getSimpleName())
.isNull();
}
}
@Test
@DisplayName("the two mechanisms keep different modification columns, which is why one is chosen")
void theTwoMechanismsKeepDifferentModificationColumns() {
assertThat(columnOf(AuditMetadata.class, "modifiedAt"))
.as("the candidate's own column name; changing it silently rewrites a schema contract")
.contains("modified_at");
assertThat(columnOf(AuditableEntity.class, "updatedAt"))
.as("the canonical column name the migrations and the sample entities already use")
.contains("updated_at");
}
@Test
@DisplayName("the canonical mechanism keeps the wider actor column")
void theCanonicalMechanismKeepsTheWiderActorColumn() {
assertThat(lengthOf(AuditableEntity.class, "updatedBy")).isEqualTo(256);
assertThat(lengthOf(AuditMetadata.class, "modifiedBy"))
.as("the two lengths are one of the reasons promotion needs a migration, not a rename")
.isEqualTo(64);
}
private static Optional<String> columnOf(Class<?> type, String fieldName) {
return field(type, fieldName).map(field -> field.getAnnotation(Column.class).name());
}
private static int lengthOf(Class<?> type, String fieldName) {
return field(type, fieldName)
.map(field -> field.getAnnotation(Column.class).length())
.orElseThrow(() -> new AssertionError(type.getSimpleName() + " has no " + fieldName));
}
private static Optional<Field> field(Class<?> type, String fieldName) {
return Arrays.stream(type.getDeclaredFields())
.filter(field -> field.getName().equals(fieldName))
.findFirst();
}
}
@@ -0,0 +1,96 @@
package dev.caskeleton.adapter.outbound.persistence.experimental;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyAwareDataSourceRouter;
import dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsTenantSessionBinder;
import dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaTenantMigrationOrchestrator;
import java.lang.reflect.Constructor;
import java.lang.reflect.Modifier;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* The experimental entry points cannot be obtained without stating consent.
*
* <p>{@code experimental/**} ships inside the main artifact — there is no separate variant — so the
* only thing standing between a deployment and tenant isolation, RLS binding or replica routing is
* whether the code can be constructed. The gate documents that presence on the classpath is not
* consent, and for a while nothing enforced it: a public constructor made one {@code new} a
* complete bypass of the flag the gate exists to require.
*
* <p>So the check is on the constructor, not on the gate's own logic. A gate that fails correctly
* when asked is worth nothing if the caller never has to ask, and the gate's unit tests cannot see
* that difference because they always ask.
*/
class ExperimentalEntryConsentTest {
/** The behaviour-bearing entry points; the surrounding value types are not activations. */
private static final List<Class<?>> ENTRY_POINTS =
List.of(
ConsistencyAwareDataSourceRouter.class,
RlsTenantSessionBinder.class,
SchemaTenantMigrationOrchestrator.class);
private final ExperimentalFeatureGate gate = new ExperimentalFeatureGate();
@Test
@DisplayName("no experimental entry point can be constructed from outside its package")
void noEntryPointCanBeConstructedFromOutsideItsPackage() {
for (Class<?> entryPoint : ENTRY_POINTS) {
for (Constructor<?> constructor : entryPoint.getDeclaredConstructors()) {
assertThat(Modifier.isPublic(constructor.getModifiers()))
.as(
"%s has a public constructor, so one `new` bypasses the flag entirely",
entryPoint.getSimpleName())
.isFalse();
}
}
}
@Test
@DisplayName("every experimental entry point offers a gate-taking factory and nothing else")
void everyEntryPointOffersAGateTakingFactory() {
for (Class<?> entryPoint : ENTRY_POINTS) {
assertThat(entryPoint.getDeclaredMethods())
.as("%s must be obtainable, and only through the gate", entryPoint.getSimpleName())
.anySatisfy(
method -> {
assertThat(method.getName()).isEqualTo("enabledBy");
assertThat(method.getReturnType()).isEqualTo(entryPoint);
assertThat(method.getParameterTypes()[0]).isEqualTo(ExperimentalFeatureGate.class);
});
}
}
@Test
@DisplayName("the RLS binder refuses to exist when its flag is absent")
void theRlsBinderRefusesToExistWithoutItsFlag() {
assertThatThrownBy(() -> RlsTenantSessionBinder.enabledBy(gate, Map.of()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining(ExperimentalFeature.MULTITENANCY_RLS.property());
}
@Test
@DisplayName("the RLS binder refuses to exist when another feature's flag is the one that is on")
void theRlsBinderRefusesAnotherFeaturesFlag() {
assertThatThrownBy(
() ->
RlsTenantSessionBinder.enabledBy(
gate, Map.of(ExperimentalFeature.READ_REPLICA.property(), true)))
.as("consent is per feature; one experimental flag is not consent to all of them")
.isInstanceOf(IllegalStateException.class);
}
@Test
@DisplayName("the RLS binder is obtainable once its own flag is explicitly true")
void theRlsBinderIsObtainableWithItsOwnFlag() {
assertThat(
RlsTenantSessionBinder.enabledBy(
gate, Map.of(ExperimentalFeature.MULTITENANCY_RLS.property(), true)))
.isNotNull();
}
}
@@ -0,0 +1,105 @@
package dev.caskeleton.adapter.outbound.persistence.platform;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* The pool lane's description matches what the pool lane does.
*
* <p>It did not. The lane was named for performance, described as certifying pool and {@code
* REQUIRES_NEW} pressure, and gated behind {@code performance.assertions.enabled} — which defaulted
* to false in the build, in the nightly job that set it explicitly, and therefore in the release
* gate that depended on it. A release could report a passed performance certification while no
* latency, throughput or pool-wait bound had ever been compared to anything.
*
* <p>The lane was renamed and the flag removed, but a removed flag leaves its name behind in the
* files that used to describe it, and a name in a document is what the next reader believes. So the
* check is textual and deliberately blunt: no file that describes this lane may mention the flag
* that no longer exists or promise a number the lane does not measure.
*/
class PoolLaneClaimTest {
/** The files whose job is to say what this lane is. */
private static final List<String> LANE_DESCRIPTIONS =
List.of(
"src/adapter/outbound/persistence-jpa/build.gradle",
"src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/PoolPressureContractTest.java",
"src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/HikariPoolSaturationContractTest.java",
"src/adapter/outbound/persistence-jpa/src/jpaPlatformPerformanceTest/java/dev/caskeleton/adapter/outbound/persistence/platform/pool/RequiresNewPoolPressureContractTest.java",
".github/workflows/jpa-nightly.yml",
"docs/jpa/repository-adaptation.md");
/**
* The flag nothing reads.
*
* <p>Split into fragments so this test does not itself become a hit for the search it performs; a
* guard that matches its own source is a guard that can never pass.
*/
private static final String REMOVED_FLAG = "performance." + "assertions." + "enabled";
@Test
@DisplayName("no file describing the pool lane mentions the flag that was removed")
void noFileMentionsTheRemovedFlag() {
for (String description : LANE_DESCRIPTIONS) {
assertThat(read(description))
.as("%s still names a property no code reads", description)
.doesNotContain(REMOVED_FLAG);
}
}
@Test
@DisplayName("no file describing the pool lane claims a certification or a machine bound")
void noFileClaimsACertificationOrAMachineBound() {
for (String description : LANE_DESCRIPTIONS) {
String text = read(description).toLowerCase(Locale.ROOT);
int laneMention = text.indexOf("pool");
if (laneMention < 0) {
continue;
}
assertThat(text)
.as("%s describes the pool lane as certifying something it does not measure", description)
.doesNotContain("pool pressure certification")
.doesNotContain("machine-dependent bounds")
.doesNotContain("machine bounds");
}
}
@Test
@DisplayName("the nightly job runs the behaviour lane, under its behaviour name")
void theNightlyJobRunsTheBehaviourLane() {
String nightly = read(".github/workflows/jpa-nightly.yml");
assertThat(nightly)
.as("the lane that runs must be the one the build registers")
.contains(":adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest");
assertThat(nightly)
.as("the old task name would silently select nothing")
.doesNotContain(":adapter:outbound:persistence-jpa:jpaPlatformPerformanceTest");
}
/** Reads a repository-relative file, locating the root by walking up. */
private static String read(String repositoryRelativePath) {
for (Path directory = Path.of("").toAbsolutePath();
directory != null;
directory = directory.getParent()) {
Path candidate = directory.resolve(repositoryRelativePath);
if (Files.isRegularFile(candidate)) {
try {
return Files.readString(candidate, StandardCharsets.UTF_8);
} catch (IOException unreadable) {
throw new IllegalStateException("cannot read " + candidate, unreadable);
}
}
}
throw new IllegalStateException(
"cannot locate " + repositoryRelativePath + " from " + Path.of("").toAbsolutePath());
}
}
@@ -0,0 +1,130 @@
package dev.caskeleton.adapter.outbound.persistence.postgresql;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName;
import dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintCode;
import dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory;
import dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException;
import dev.caskeleton.adapter.outbound.persistence.api.error.UniqueConstraintViolationException;
import dev.caskeleton.adapter.outbound.persistence.api.error.VendorFailureTranslator;
import dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintCatalog;
import dev.caskeleton.adapter.outbound.persistence.postgresql.error.ConstraintCatalog;
import dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlExceptionTranslator;
import java.sql.SQLException;
import java.time.Duration;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.ObjectProvider;
/**
* The vendor composition supplies the translator the transaction executor classifies with.
*
* <p>Nothing registered a {@link VendorFailureTranslator}, so every executor was built with the
* catalog-free chain, whose vendor stage returns failures unchanged. A 40001 or 40P01 stayed a raw
* {@code DataAccessException}, missed the retry coordinator's {@code JpaPersistenceException}
* catch, and was never retried — while {@code PostgreSqlExceptionTranslator}, the class that knows
* how to classify both, had no production caller at all.
*/
class PostgreSqlVendorFailureTranslatorRegistrationTest {
private static final PersistenceOperationName OPERATION =
new PersistenceOperationName("order.write");
private static final ConstraintCode ACTIVE_EMAIL = new ConstraintCode("user.active-email.unique");
private final PostgreSqlPersistenceConfig configuration = new PostgreSqlPersistenceConfig();
@Test
@DisplayName("a serialization failure is classified as retryable contention")
void aSerializationFailureIsClassifiedAsRetryable() {
JpaPersistenceException classified =
translate(absent(), new SQLException("could not serialize access", "40001"));
assertThat(classified.category()).isEqualTo(FailureCategory.SERIALIZATION_FAILURE);
assertThat(classified.retryable())
.as("the coordinator retries what is marked retryable, and nothing else")
.isTrue();
}
@Test
@DisplayName("a deadlock is classified as retryable contention")
void aDeadlockIsClassifiedAsRetryable() {
JpaPersistenceException classified =
translate(absent(), new SQLException("deadlock detected", "40P01"));
assertThat(classified.category()).isEqualTo(FailureCategory.DEADLOCK);
assertThat(classified.retryable()).isTrue();
}
@Test
@DisplayName("a unique violation is classified and stays terminal")
void aUniqueViolationStaysTerminal() {
JpaPersistenceException classified =
translate(absent(), new SQLException("duplicate key", "23505"));
assertThat(classified).isInstanceOf(UniqueConstraintViolationException.class);
assertThat(classified.retryable())
.as("retrying a duplicate key writes the row a second time")
.isFalse();
}
/**
* A registered catalog is taken, and its absence is not an error.
*
* <p>The catalog is optional on purpose: an empty one still classifies the SQLSTATE, which is
* what retry depends on, and resolves every constraint name to the unknown code — the right
* answer for a constraint the application never registered. Requiring one would make a relational
* deployment fail to start over an error-reporting detail.
*/
@Test
@DisplayName("the application's own constraint catalog is taken when it registered one")
void theApplicationsCatalogIsTakenWhenPresent() {
ConstraintCatalog registered =
new PostgreSqlConstraintCatalog(Map.of("ux_user_active_email", ACTIVE_EMAIL));
assertThat(configuration.postgreSqlVendorFailureTranslator(available(registered)))
.isInstanceOf(PostgreSqlExceptionTranslator.class);
assertThat(configuration.postgreSqlVendorFailureTranslator(absent()))
.isInstanceOf(PostgreSqlExceptionTranslator.class);
}
private JpaPersistenceException translate(
ObjectProvider<ConstraintCatalog> catalogs, Throwable failure) {
return configuration
.postgreSqlVendorFailureTranslator(catalogs)
.translate(failure, OPERATION, 1, Duration.ZERO, null);
}
/** An application that registered no catalog of its own. */
private static ObjectProvider<ConstraintCatalog> absent() {
return provider(List.of());
}
/** An application that registered exactly one. */
private static ObjectProvider<ConstraintCatalog> available(ConstraintCatalog catalog) {
return provider(List.of(catalog));
}
private static ObjectProvider<ConstraintCatalog> provider(List<ConstraintCatalog> candidates) {
return new ObjectProvider<>() {
@Override
public ConstraintCatalog getObject() {
if (candidates.isEmpty()) {
throw new NoSuchBeanDefinitionException(ConstraintCatalog.class);
}
return candidates.get(0);
}
@Override
public Iterator<ConstraintCatalog> iterator() {
return candidates.iterator();
}
};
}
}
@@ -0,0 +1,119 @@
package dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import dev.caskeleton.application.idempotency.RequestFingerprint;
import dev.caskeleton.application.idempotency.v2.IdempotencyClaimRequest;
import dev.caskeleton.application.idempotency.v2.IdempotencyScopeDigest;
import dev.caskeleton.application.transaction.OperationId;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.time.Duration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* The three things that must be true before a claim writes anything.
*
* <p>Now that a deployment can select this store by property, "which database is behind it" stops
* being a question the test harness answers and becomes one an operator can get wrong. The store's
* statements are PostgreSQL — {@code on conflict ... do nothing}, {@code clock_timestamp()}, {@code
* for update} — so on any other vendor the first claim has to refuse rather than discover the
* mismatch halfway through a mutation.
*
* <p>Each case asserts that no statement ran, not only that an exception was thrown. A guard that
* fails after its first write is not a guard.
*/
class OwnerSafeIdempotencyPreconditionTest {
private static final IdempotencyScopeDigest SCOPE =
new IdempotencyScopeDigest("b".repeat(64), 1, "CREATE_WORK_LOG");
private final JdbcOperations jdbc = mock(JdbcOperations.class);
private final PostgreSqlOwnerSafeIdempotencyStore store =
new PostgreSqlOwnerSafeIdempotencyStore(jdbc);
@AfterEach
void clearTransactionState() {
TransactionSynchronizationManager.setActualTransactionActive(false);
TransactionSynchronizationManager.setCurrentTransactionReadOnly(false);
}
@Test
@DisplayName("a claim with no transaction is refused before any statement runs")
void aClaimWithNoTransactionIsRefused() {
assertThatThrownBy(() -> store.claim(claimRequest()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("active primary transaction");
verifyNothingWasWritten();
}
@Test
@DisplayName("a claim against another vendor is refused before any statement runs")
void aClaimAgainstAnotherVendorIsRefused() throws Exception {
TransactionSynchronizationManager.setActualTransactionActive(true);
reportDatabaseProduct("H2");
assertThatThrownBy(() -> store.claim(claimRequest()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("requires PostgreSQL")
.hasMessageContaining("H2");
verifyNothingWasWritten();
}
@Test
@DisplayName("a claim against an unsanctioned schema stream is refused before any statement runs")
void aClaimAgainstAnUnsanctionedStreamIsRefused() throws Exception {
TransactionSynchronizationManager.setActualTransactionActive(true);
reportDatabaseProduct("PostgreSQL");
when(jdbc.queryForObject(anyString(), eq(Integer.class))).thenReturn(0);
assertThatThrownBy(() -> store.claim(claimRequest()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("jpa-idempotency-owner-safe-v2 is not active");
verifyNothingWasWritten();
}
private void reportDatabaseProduct(String product) throws Exception {
DatabaseMetaData metaData = mock(DatabaseMetaData.class);
when(metaData.getDatabaseProductName()).thenReturn(product);
Connection connection = mock(Connection.class);
when(connection.getMetaData()).thenReturn(metaData);
when(jdbc.execute(ArgumentMatchers.<ConnectionCallback<String>>any()))
.thenAnswer(
invocation ->
invocation.getArgument(0, ConnectionCallback.class).doInConnection(connection));
}
private void verifyNothingWasWritten() {
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
private static IdempotencyClaimRequest claimRequest() {
return new IdempotencyClaimRequest(
SCOPE,
RequestFingerprint.ofSha256("request".getBytes(StandardCharsets.UTF_8)),
new dev.caskeleton.application.idempotency.v2.IdempotencyClaimAttempt(
"c".repeat(64), new OperationId("claim-precondition")),
Duration.ofSeconds(30),
Duration.ofHours(1),
"json.v1",
2);
}
}
@@ -0,0 +1,69 @@
package dev.caskeleton.adapter.outbound.persistence.testkit;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.persistence.testkit.arch.JpaAuditMechanismRule;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* The bulk-update audit rule, with its failing case executed.
*
* <p>The production graph contains no bulk update of an audited entity today, so running the rule
* over the graph can only ever show it passing — which is the same evidence an empty rule would
* produce. The decision the rule encodes is exercised here directly, against statements written for
* the purpose, so the rule is known to distinguish the two cases rather than assumed to.
*/
class JpaAuditMechanismRuleTest {
private static final Set<String> AUDITED = Set.of("WorkLogEntity", "PosterEntity");
@Test
@DisplayName("a bulk update of an audited entity that stamps nothing is a violation")
void aBulkUpdateThatStampsNothingIsAViolation() {
assertThat(
JpaAuditMechanismRule.bulkUpdateViolation(
"update WorkLogEntity w set w.status = :status where w.id = :id", AUDITED))
.as("the row would keep the timestamp of whoever last saved it through the adapter")
.isPresent();
}
@Test
@DisplayName("a bulk update that stamps the audit column is accepted")
void aBulkUpdateThatStampsTheAuditColumnIsAccepted() {
assertThat(
JpaAuditMechanismRule.bulkUpdateViolation(
"update WorkLogEntity w set w.status = :status, w.updatedAt = :now where w.id = :id",
AUDITED))
.isEmpty();
}
@Test
@DisplayName("a bulk update of an entity that uses no audit mechanism is not the rule's business")
void aBulkUpdateOfAnUnauditedEntityIsNotTheRulesBusiness() {
assertThat(
JpaAuditMechanismRule.bulkUpdateViolation(
"update UploadSessionEntity u set u.state = :state where u.id = :id", AUDITED))
.isEmpty();
}
@Test
@DisplayName("a select over an audited entity is not a bulk update")
void aSelectOverAnAuditedEntityIsNotABulkUpdate() {
assertThat(
JpaAuditMechanismRule.bulkUpdateViolation(
"select w from WorkLogEntity w where w.id = :id", AUDITED))
.isEmpty();
}
@Test
@DisplayName("the Spring Data mechanism's column name counts as stamping too")
void theSpringDataColumnNameCountsAsStamping() {
assertThat(
JpaAuditMechanismRule.bulkUpdateViolation(
"update PosterEntity p set p.title = :title, p.modifiedAt = :now", AUDITED))
.as("both mechanisms exist, so both column families satisfy the policy")
.isEmpty();
}
}
@@ -0,0 +1,229 @@
package dev.caskeleton.adapter.outbound.persistence.testkit;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.persistence.testkit.release.JpaReleaseManifest;
import dev.caskeleton.adapter.outbound.persistence.testkit.release.JpaReleaseRendering;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* The registry's two renderings, compared to the registry.
*
* <p>Moving the support claims into a typed registry stopped a version from being supported because
* a paragraph mentioned it. It did not stop the opposite: the support document and the release
* workflow still carry their own hand-written major lists, support levels and gate table, and
* nothing compared them to the registry. Demoting PostgreSQL 17 there would have left the document
* calling it Stable and the workflow still running a full job against it — the same class of false
* evidence, one file over.
*
* <p>Each drift case is also executed against a mutated copy, because a comparison nobody has
* watched fail is a comparison of unknown shape.
*/
class JpaReleaseRenderingTest {
private final JpaReleaseManifest manifest = JpaReleaseManifest.loadFromRepository();
private final String supportMatrix = read("docs/jpa/support-matrix.md");
private final String releaseWorkflow = read(".github/workflows/jpa-release.yml");
private final String nightlyWorkflow = read(".github/workflows/jpa-nightly.yml");
@Test
@DisplayName("the support document states the registry's support level for every major")
void theDocumentStatesTheRegistrysSupportLevels() {
Map<Integer, String> documented = JpaReleaseRendering.documentedSupportLevels(supportMatrix);
assertThat(documented.entrySet())
.as("a major in the document and not in the registry is a support claim nothing backs")
.allSatisfy(
row ->
assertThat(expectedLevel(row.getKey()))
.as("PostgreSQL %d", row.getKey())
.isEqualTo(row.getValue()));
assertThat(documented.keySet())
.containsExactlyInAnyOrderElementsOf(
java.util.stream.Stream.concat(
manifest.stableVersions().stream(), manifest.experimentalVersions().stream())
.toList());
}
@Test
@DisplayName("a demotion in the registry that the document does not follow is caught")
void aDemotionTheDocumentDoesNotFollowIsCaught() {
String demoted = supportMatrix.replace("| PostgreSQL 17 | Stable |", "| PostgreSQL 17 | Bad |");
assertThat(JpaReleaseRendering.documentedSupportLevels(demoted).get(17))
.as(
"the check reads the document's own words, so a level the registry does not state fails")
.isNotEqualTo(expectedLevel(17));
}
@Test
@DisplayName("the document's gate table is exactly the registry's gates")
void theDocumentsGateTableIsExactlyTheRegistrysGates() {
assertThat(JpaReleaseRendering.documentedGates(supportMatrix))
.as("a gate deleted from the registry must not keep a row that says it is checked")
.containsExactlyInAnyOrderElementsOf(manifest.gates());
}
@Test
@DisplayName("a gate row the registry does not declare is caught")
void aGateRowTheRegistryDoesNotDeclareIsCaught() {
String invented =
supportMatrix.replace(
"| `osiv-disabled` | gate |", "| `osiv-disabled` | gate |\n| `invented-gate` | gate |");
assertThat(JpaReleaseRendering.documentedGates(invented))
.as("a gate table row with no registry entry is drift the positive case forbids")
.isNotEqualTo(manifest.gates());
}
@Test
@DisplayName("the release workflow fans out to exactly the Stable majors")
void theReleaseWorkflowFansOutToExactlyTheStableMajors() {
assertThat(JpaReleaseRendering.workflowMatrixMajors(releaseWorkflow))
.as("a major the registry does not call Stable must not get a full release job")
.containsExactlyElementsOf(manifest.stableVersions());
}
@Test
@DisplayName("the promotion job requires evidence for exactly the Stable majors")
void thePromotionJobRequiresEvidenceForExactlyTheStableMajors() {
assertThat(JpaReleaseRendering.workflowPromotionMajors(releaseWorkflow))
.as("promotion that checks fewer majors than the matrix runs ignores a failed one")
.containsExactlyElementsOf(manifest.stableVersions());
}
@Test
@DisplayName("a workflow matrix that drops a Stable major is caught")
void aWorkflowMatrixThatDropsAStableMajorIsCaught() {
String dropped = releaseWorkflow.replace("[\"16\", \"17\", \"18\"]", "[\"16\", \"17\"]");
assertThat(JpaReleaseRendering.workflowMatrixMajors(dropped))
.isNotEqualTo(manifest.stableVersions());
}
@Test
@DisplayName("the nightly matrix fans out to exactly the Stable majors")
void theNightlyMatrixFansOutToExactlyTheStableMajors() {
assertThat(JpaReleaseRendering.workflowMatrixMajors(nightlyWorkflow))
.as(
"the release lane was compared to the registry and the nightly lane was not, so a "
+ "demotion corrected one workflow and left the other certifying the major")
.containsExactlyElementsOf(manifest.stableVersions());
}
@Test
@DisplayName("a nightly matrix that certifies an unregistered major is caught")
void aNightlyMatrixThatCertifiesAnUnregisteredMajorIsCaught() {
String widened =
nightlyWorkflow.replace("[\"16\", \"17\", \"18\"]", "[\"16\", \"17\", \"18\", \"19\"]");
assertThat(JpaReleaseRendering.workflowMatrixMajors(widened))
.as("collecting full-contract evidence for a major is the promotion the gate owns")
.isNotEqualTo(manifest.stableVersions());
}
@Test
@DisplayName("every Experimental major is named as the target of a compatibility lane")
void everyExperimentalMajorIsNamedByACompatibilityLane() {
assertThat(compatibilityTargetsAcrossWorkflows())
.as(
"'compatibility lane only' has to resolve to a file that records that major as its "
+ "target, or the support level is a claim the reader cannot check")
.containsAll(manifest.experimentalVersions());
}
@Test
@DisplayName("declaring a major Experimental is not enough on its own")
void declaringAMajorExperimentalIsNotEnoughOnItsOwn() {
JpaReleaseManifest invented =
JpaReleaseManifest.parse(
"""
{"databases":[{"major":16,"support-level":"stable"},
{"major":99,"support-level":"experimental"}],
"provider":{"stable-tested-baseline":"7.1.8.Final"},
"gates":[{"name":"osiv-disabled","task":":a:test"}]}
""");
assertThat(compatibilityTargetsAcrossWorkflows())
.as("the positive case must fail for a major nobody wrote a lane for")
.doesNotContainAnyElementsOf(invented.experimentalVersions());
}
@Test
@DisplayName("no Experimental major collects evidence from a Stable lane")
void noExperimentalMajorCollectsEvidenceFromAStableLane() {
List<Integer> stableLaneMajors = new ArrayList<>();
stableLaneMajors.addAll(JpaReleaseRendering.workflowExecutedMajors(releaseWorkflow));
stableLaneMajors.addAll(JpaReleaseRendering.workflowExecutedMajors(nightlyWorkflow));
assertThat(stableLaneMajors)
.as("a compatibility target that also runs the full suite is Stable in everything but name")
.doesNotContainAnyElementsOf(manifest.experimentalVersions());
}
private String expectedLevel(int major) {
if (manifest.stableVersions().contains(major)) {
return "stable";
}
if (manifest.experimentalVersions().contains(major)) {
return "experimental";
}
return "unregistered";
}
/**
* Every major any compatibility lane in the workflow directory records as its target.
*
* <p>The whole directory rather than a named file: the check is "some lane targets this major",
* and naming the file here would make the test pass by pointing at the one lane that exists.
*/
private static List<Integer> compatibilityTargetsAcrossWorkflows() {
List<Integer> targets = new ArrayList<>();
Path directory = locate(".github/workflows");
try (var entries = Files.list(directory)) {
for (Path workflow : entries.sorted().toList()) {
if (workflow.getFileName().toString().endsWith(".yml")) {
targets.addAll(
JpaReleaseRendering.compatibilityLaneTargets(
Files.readString(workflow, StandardCharsets.UTF_8)));
}
}
} catch (IOException unreadable) {
throw new IllegalStateException(
"cannot read the workflow directory " + directory, unreadable);
}
return targets;
}
/** Reads a repository-relative file, locating the root by walking up. */
private static String read(String repositoryRelativePath) {
Path path = locate(repositoryRelativePath);
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException unreadable) {
throw new IllegalStateException("cannot read " + path, unreadable);
}
}
/** Locates a repository-relative file or directory by walking up from the working directory. */
private static Path locate(String repositoryRelativePath) {
for (Path directory = Path.of("").toAbsolutePath();
directory != null;
directory = directory.getParent()) {
Path candidate = directory.resolve(repositoryRelativePath);
if (Files.exists(candidate)) {
return candidate;
}
}
throw new IllegalStateException(
"cannot locate " + repositoryRelativePath + " from " + Path.of("").toAbsolutePath());
}
}
@@ -1,13 +1,22 @@
package dev.caskeleton.adapter.outbound.persistence.testkit.arch;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.domain.JavaMethod;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
import jakarta.persistence.Entity;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* An entity uses one audit mechanism, or none — never both.
@@ -43,6 +52,142 @@ public final class JpaAuditMechanismRule {
.allowEmptyShould(true);
}
/** The simple names of the entities in {@code classes} that use either audit mechanism. */
public static Set<String> auditedEntityNames(JavaClasses classes) {
return classes.stream()
.filter(type -> type.isAnnotatedWith(Entity.class))
.filter(JpaAuditMechanismRule::usesAnAuditMechanism)
.map(JavaClass::getSimpleName)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
* A bulk update of an audited entity must stamp the audit columns itself.
*
* <p>Neither mechanism reaches a bulk statement. The explicit base stamps in the repository
* adapter's {@code save}; the Spring Data listener fires on a managed entity's lifecycle. A JPQL
* or native {@code update} goes straight to the database, so an audited row updated in bulk keeps
* whatever {@code updated_at} and {@code updated_by} it had — and the audit trail then says the
* row was last touched by whoever last saved it through the adapter, which is a wrong answer
* rather than a missing one.
*
* <p>The rule takes the audited names rather than deriving them, because the check runs over the
* composition root's view of every leaf while the entities live in another one. Passing the set
* in keeps the rule honest about what it is comparing against.
*
* @param auditedEntities simple names of entities that use an audit mechanism
*/
public static ArchRule bulkUpdatesOfAuditedEntitiesStampAudit(Set<String> auditedEntities) {
Set<String> audited = Set.copyOf(auditedEntities);
return methods()
.that(
new DescribedPredicate<JavaMethod>("are annotated with Spring Data @Modifying") {
@Override
public boolean test(JavaMethod method) {
return method.getAnnotations().stream()
.anyMatch(annotation -> MODIFYING.equals(annotation.getRawType().getName()));
}
})
.should(new StampsAuditOnBulkUpdate(audited))
.as("a bulk update of an audited entity stamps the audit columns in the statement")
.because(
"no audit mechanism reaches a bulk statement, so an unstamped one leaves the row"
+ " claiming it was last modified by the previous ordinary save")
.allowEmptyShould(true);
}
/** Whether {@code entity} inherits the explicitly stamped audit base. */
private static boolean extendsAuditableEntity(JavaClass entity) {
for (JavaClass current = entity;
current != null;
current = current.getRawSuperclass().orElse(null)) {
if (current.getName().equals(EXPLICIT_BASE)) {
return true;
}
}
return false;
}
/** Whether {@code entity} uses either audit mechanism. */
private static boolean usesAnAuditMechanism(JavaClass entity) {
return extendsAuditableEntity(entity)
|| entity.getAllFields().stream()
.anyMatch(field -> field.getRawType().getName().equals(SPRING_DATA_EMBEDDABLE));
}
/**
* The violation in one query, when there is one.
*
* <p>Exposed so both branches can be executed directly. An architecture rule whose passing and
* failing cases have never been run is a rule of unknown shape, and the production graph happens
* to contain no audited bulk update today — so the graph alone can only ever show it passing.
*
* @param query the JPQL or native statement
* @param auditedEntities simple names of entities that use an audit mechanism
* @return the reason this query violates the rule, or empty
*/
public static Optional<String> bulkUpdateViolation(String query, Set<String> auditedEntities) {
String normalized = query.trim().toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
if (!normalized.startsWith("update ")) {
return Optional.empty();
}
String target = normalized.substring("update ".length()).split(" ", -1)[0];
boolean audited =
auditedEntities.stream().anyMatch(name -> name.toLowerCase(Locale.ROOT).equals(target));
if (!audited) {
return Optional.empty();
}
int setClause = normalized.indexOf(" set ");
if (setClause < 0) {
return Optional.of("updates audited '" + target + "' with no set clause at all");
}
int whereClause = normalized.indexOf(" where ", setClause);
String assignments =
whereClause < 0
? normalized.substring(setClause)
: normalized.substring(setClause, whereClause);
boolean stamps = AUDIT_COLUMNS.stream().anyMatch(assignments::contains);
return stamps
? Optional.empty()
: Optional.of("updates audited '" + target + "' without stamping an audit column");
}
/** The Spring Data annotation that marks a repository method as a bulk statement. */
private static final String MODIFYING = "org.springframework.data.jpa.repository.Modifying";
/** The Spring Data annotation carrying the statement text. */
private static final String QUERY = "org.springframework.data.jpa.repository.Query";
/** The assignments that count as stamping, across both mechanisms' column names. */
private static final Set<String> AUDIT_COLUMNS =
Set.of("updatedat", "updated_at", "modifiedat", "modified_at");
private static final class StampsAuditOnBulkUpdate extends ArchCondition<JavaMethod> {
private final Set<String> auditedEntities;
StampsAuditOnBulkUpdate(Set<String> auditedEntities) {
super("stamp the audit columns when the target entity is audited");
this.auditedEntities = auditedEntities;
}
@Override
public void check(JavaMethod method, ConditionEvents events) {
method.getAnnotations().stream()
.filter(annotation -> QUERY.equals(annotation.getRawType().getName()))
.map(annotation -> annotation.get("value").orElse(""))
.map(Object::toString)
.forEach(
statement ->
bulkUpdateViolation(statement, auditedEntities)
.ifPresent(
reason ->
events.add(
SimpleConditionEvent.violated(
method, method.getFullName() + " " + reason))));
}
}
private static final class SingleAuditMechanism extends ArchCondition<JavaClass> {
SingleAuditMechanism() {
@@ -65,16 +210,5 @@ public final class JpaAuditMechanismRule {
+ " and two column families"));
}
}
private static boolean extendsAuditableEntity(JavaClass entity) {
for (JavaClass current = entity;
current != null;
current = current.getRawSuperclass().orElse(null)) {
if (current.getName().equals(EXPLICIT_BASE)) {
return true;
}
}
return false;
}
}
}
@@ -0,0 +1,144 @@
package dev.caskeleton.adapter.outbound.persistence.testkit.release;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* What the support document and the release workflow say, extracted so it can be compared.
*
* <p>The registry became the source of truth and nothing checked that its two renderings agreed
* with it. A document that opens by calling itself a rendering is a claim, not a mechanism: the
* major list, the support levels, the gate table and the workflow's matrix were all still typed by
* hand, so demoting a major in the registry left three places saying it was Stable and one job per
* major still running against it.
*
* <p>These readers take text rather than paths so a drift check can be shown to fail on a mutated
* copy. A comparison whose negative case has never been executed is a comparison of unknown shape.
*/
public final class JpaReleaseRendering {
private static final Pattern DOCUMENT_DATABASE_ROW =
Pattern.compile("^\\|\\s*PostgreSQL (\\d+)\\s*\\|\\s*([A-Za-z]+)\\s*\\|", Pattern.MULTILINE);
private static final Pattern DOCUMENT_GATE_ROW =
Pattern.compile("^\\|\\s*`([a-z0-9-]+)`\\s*\\|\\s*gate\\s*\\|", Pattern.MULTILINE);
private static final Pattern WORKFLOW_MATRIX = Pattern.compile("postgresql:\\s*\\[([^\\]]*)\\]");
private static final Pattern WORKFLOW_PROMOTION_LOOP = Pattern.compile("for major in ([0-9 ]+);");
private static final Pattern QUOTED_MAJOR = Pattern.compile("\"(\\d+)\"");
private static final Pattern COMPATIBILITY_TARGET =
Pattern.compile("target=PostgreSQL\\s+(\\d+)");
private JpaReleaseRendering() {}
/**
* The support level the document states for each major, keyed by major in document order.
*
* <p>Only the database table's rows match: the pattern anchors on a table cell, so a major named
* in a paragraph is not a claim of support. That was the original defect in the other direction —
* prose counted as a declaration.
*/
public static Map<Integer, String> documentedSupportLevels(String document) {
Objects.requireNonNull(document, "document");
Map<Integer, String> levels = new LinkedHashMap<>();
Matcher rows = DOCUMENT_DATABASE_ROW.matcher(document);
while (rows.find()) {
levels.put(Integer.parseInt(rows.group(1)), rows.group(2).toLowerCase(java.util.Locale.ROOT));
}
return levels;
}
/** The gate names the document's gate table lists, in document order. */
public static List<String> documentedGates(String document) {
Objects.requireNonNull(document, "document");
List<String> gates = new ArrayList<>();
Matcher rows = DOCUMENT_GATE_ROW.matcher(document);
while (rows.find()) {
gates.add(rows.group(1));
}
return gates;
}
/** The majors the release workflow fans out to, one job each. */
public static List<Integer> workflowMatrixMajors(String workflow) {
Objects.requireNonNull(workflow, "workflow");
Matcher matrix = WORKFLOW_MATRIX.matcher(workflow);
if (!matrix.find()) {
throw new IllegalArgumentException("the release workflow declares no PostgreSQL matrix");
}
List<Integer> majors = new ArrayList<>();
Matcher quoted = QUOTED_MAJOR.matcher(matrix.group(1));
while (quoted.find()) {
majors.add(Integer.parseInt(quoted.group(1)));
}
return majors;
}
/**
* The majors the promotion job requires evidence for.
*
* <p>Read separately from the matrix on purpose. They are two hand-written lists in one file, and
* a promotion that requires fewer majors than the matrix produces is a promotion that ignores a
* failed one.
*/
public static List<Integer> workflowPromotionMajors(String workflow) {
Objects.requireNonNull(workflow, "workflow");
Matcher loop = WORKFLOW_PROMOTION_LOOP.matcher(workflow);
if (!loop.find()) {
throw new IllegalArgumentException("the release workflow declares no promotion major list");
}
List<Integer> majors = new ArrayList<>();
for (String major : loop.group(1).trim().split("\\s+", -1)) {
majors.add(Integer.parseInt(major));
}
return majors;
}
/**
* Every major some job in a workflow fans out to.
*
* <p>{@link #workflowMatrixMajors} reads the first matrix it finds and is the exactness check for
* the release lane. This answers a weaker but differently useful question — does any job in this
* file start a container of this major — so a second workflow can be compared to the registry
* without assuming it declares exactly one matrix.
*/
public static List<Integer> workflowExecutedMajors(String workflow) {
Objects.requireNonNull(workflow, "workflow");
List<Integer> majors = new ArrayList<>();
Matcher matrices = WORKFLOW_MATRIX.matcher(workflow);
while (matrices.find()) {
Matcher quoted = QUOTED_MAJOR.matcher(matrices.group(1));
while (quoted.find()) {
majors.add(Integer.parseInt(quoted.group(1)));
}
}
return majors;
}
/**
* The majors a compatibility lane records itself as targeting.
*
* <p>An Experimental major is declared "compatibility lane only" and nothing tied that phrase to
* a file. The lane does exist — it runs weekly and records {@code NOT_EXECUTABLE} with its reason
* — but the registry, the support document and the release workflow could each be read end to end
* without establishing that. A reader who cannot find the lane concludes there is none, which is
* the conclusion this repository's own review pass reached before checking the workflow
* directory.
*
* <p>The target is read from the status artifact the lane writes rather than from its filename. A
* filename is a label; the artifact is what a promotion decision would actually be read from, so
* a lane that stops recording its target stops counting as a lane.
*/
public static List<Integer> compatibilityLaneTargets(String workflow) {
Objects.requireNonNull(workflow, "workflow");
List<Integer> targets = new ArrayList<>();
Matcher declared = COMPATIBILITY_TARGET.matcher(workflow);
while (declared.find()) {
targets.add(Integer.parseInt(declared.group(1)));
}
return targets;
}
}
@@ -31,12 +31,19 @@ design package's assumed module layout onto this leaf lives in
## Allowed
- No project dependency at all. The registry entry's `allowed_dependencies` is `[]`, matching what
the build actually uses; `application-core` and `shared-contract` were listed and unused, which is
a permission granted in advance for an adapter nobody has approved yet.
- `runtime_memberships` is `[]` and no composition root depends on this leaf. Property-only
activation switches on a module that is already on the classpath; it does not put one there. A
fork that wants it in a runtime adds the membership and the dependency in the same approved
change.
the build actually uses; `domain-core`, `application-core` and `shared-contract` were listed and
unused, which is a permission granted in advance for an adapter nobody has approved yet.
`verifyCleanArchitectureDependencies` only checks that resolved edges are a subset of the declared
ones, so an unused permission passes every run; `MongoRegistryPermissionParityTest` checks the
other direction and fails when the two sets differ.
- `runtime_memberships` is `["app-bootstrap"]`, and the composition root really does declare
`implementation(project(':adapter:outbound:persistence-mongo'))` — with the reactive starter and
the reactivestreams driver excluded, because there is no reactive port in the shipped Stable
scope. `RuntimeMembershipClasspathAgreementTest` compares the registry against the resolved
runtime classpath, so the membership cannot drift from what the jar carries. Property-only
activation therefore works here: the switch turns on a module that already ships, and shipping it
off is not the same contract as leaving it out, because absence cannot be reversed at deploy time
and hides every gating defect. `sample-portfolio` does not carry it.
- External: `spring-boot-starter-data-mongodb` and `-reactive`, `spring-boot-autoconfigure`,
`micrometer-core`, `slf4j-api`, `spring-boot-configuration-processor` (annotation processor).
Versions come from the shared Spring Boot BOM; never pin the driver directly.
@@ -73,14 +80,19 @@ unsupported rather than silently dropped.
### Public surface
341 public top-level types live in one jar, so `public` means public to every adopter regardless of
which package it sits in. `verifyMongoApiSurface` (in `check`) compares the surface against
`docs/architecture/mongo-api-surface.txt`; growing it takes
Every public top-level type in one jar means `public` is public to every adopter regardless of which
package it sits in. `verifyMongoApiSurface` (in `check`) compares the surface against
`docs/architecture/mongo-api-surface.txt`, which carries the count; growing it takes
`updateMongoApiSurface -PapproveMongoApiSurfaceChange`, which is a review decision.
The architecture rule catalogue (`…mongo.architecture`) is in the **testkit** source set, not
production: it is ArchUnit input, and shipping it put rule text on every consumer's runtime
classpath.
classpath. Release gating is testkit-only for the same reason and is one implementation, not two:
`…mongo.testkit.release` reads the JUnit XML a lane wrote and is what
`scripts/verify-mongodb-platform.sh` and `src/config/mongodb/release-contracts.json` drive. A second
pair on the production classpath — a hand-built set of category names and a gate that checked it —
had no caller outside its own test and no source of truth behind the categories; it is gone rather
than moved.
Still pending, and deliberately not done as part of a review sweep: moving implementation packages
under an `internal` root and lowering visibility inside them. That is a mechanical change over ~200
@@ -11,20 +11,24 @@ document/repository/mapper와 application 또는 domain port 구현을 추가할
## 활성화
### shipped runtime에 들어 있지 않다 (library-only opt-in)
### shipped runtime에 들어 있고, property가 그 스위치다
property를 켜는 것만으로는 이 leaf가 애플리케이션에 들어오지 않는다. registry의
`runtime_memberships`는 빈 배열이고, shipped `app-bootstrap``sample-portfolio`는 이 leaf에
project dependency를 두지 않는다. 즉 `ca-skeleton.persistence-mongo.enabled=true`는 **이미 classpath
올라온 모듈**을 켜는 스위치이지, 모듈을 추가하는 스위치가 아니다.
`app-bootstrap`은 이 leaf에 project dependency를 두고(`src/app-bootstrap/build.gradle`,
reactive starter와 reactivestreams driver는 exclude), registry의 `runtime_memberships`
`["app-bootstrap"]`이다. 두 사실은 `RuntimeMembershipClasspathAgreementTest`가 runtime classpath
비교해 붙잡는다. 그래서 `ca-skeleton.persistence-mongo.enabled=true`는 실제로 동작하는 master
switch다 — 없는 모듈을 부르는 property가 아니라, 이미 jar에 들어 있는 모듈을 켜는 스위치다.
consumer가 실제로 쓰려면 registry(`src/config/architecture/modules.json`)의 runtime membership과
composition root의 dependency를 함께 승인해서 추가해야 한다. 그 승인 없이 property만 켜면 아무 일도
일어나지 않는다 — 아래 설명은 모두 그 전제 위에 있다.
빠져 있는 모듈은 꺼진 모듈과 같은 계약이 아니다. 부재는 배포 시점에 되돌릴 수 없고, gating 결함을
전부 가린다. 존재하지 않는 코드는 자기 condition이 무엇이라고 말하든 bean을 하나도 들고 있지 않기
때문이다. `sample-portfolio`는 이 leaf를 싣지 않는다.
`allowed_dependencies`도 같은 이유로 `[]`다. 지금 이 leaf는 어떤 project dependency도 쓰지 않으므로,
`application-core`/`shared-contract`를 열어 두는 것은 "언젠가 쓸지도 모른다"를 registry가 미리 허용해
주는 것이고, 그 허용은 실제 도메인 adapter가 승인될 때 함께 추가되어야 한다.
`allowed_dependencies` `[]`다. 이 leaf는 어떤 project dependency도 쓰지 않으므로
`domain-core`/`application-core`/`shared-contract`를 열어 두는 것은 "언젠가 쓸지도 모른다"를 registry가
미리 허용해 주는 것이었다. `verifyCleanArchitectureDependencies`는 실제 edge가 허용 집합의 부분집합인지만
보므로 쓰이지 않는 허용은 영원히 통과한다. `MongoRegistryPermissionParityTest`가 반대 방향 —
허용 집합과 build가 선언한 project dependency가 정확히 같은지 — 를 확인한다. 실제 도메인 Mongo adapter가
application port를 구현하게 되면 build와 registry를 같은 변경에서 함께 넓힌다.
기본값은 비활성이다.
@@ -4,7 +4,7 @@
// modules map here).
//
// The design models the platform as 19 Stable and 12 Advanced Gradle modules under
// `modules/mongodb` and `modules/mongodb-advanced`. This repository's fail-closed 19-leaf registry
// `modules/mongodb` and `modules/mongodb-advanced`. This repository's fail-closed module registry
// (src/config/architecture/modules.json) outranks that layout, so the module boundaries are
// packages under dev.caskeleton.adapter.outbound.mongo. MongoModuleBoundaryTest holds a closed
// edge matrix — every package and what it may import — compares it against the tree for exact
@@ -43,33 +43,20 @@ dependencies {
// The testkit is its own source set rather than part of `test` because several lanes consume it and
// because the design forbids a production module from depending on the testkit. Declaring its
// dependencies only on the test configurations gives that guarantee without a new Gradle project.
sourceSets {
testkit {
java.srcDir 'src/testkit/java'
resources.srcDir 'src/testkit/resources'
compileClasspath += sourceSets.main.output
runtimeClasspath += output + compileClasspath
}
mongoPerformanceTest {
java.srcDir 'src/mongoPerformanceTest/java'
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
runtimeClasspath += output + compileClasspath
}
}
configurations {
// The testkit compiles against exactly what a test does: testImplementation already extends
// implementation, so this is the module's own dependencies plus the test libraries.
testkitImplementation.extendsFrom testImplementation
testkitRuntimeOnly.extendsFrom testRuntimeOnly
mongoPerformanceTestImplementation.extendsFrom testImplementation
mongoPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly
strictTestLanes {
// The testkit compiles against exactly what a test does: `implementation` inheritance runs
// through testImplementation, so this is the module's own dependencies plus the test libraries.
sourceSet('testkit') { compilesAgainst 'main' }
sourceSet('mongoPerformanceTest') { compilesAgainst 'main', 'testkit' }
}
// Every test lane compiles and runs against the testkit.
sourceSets.test {
compileClasspath += sourceSets.testkit.output
runtimeClasspath += sourceSets.testkit.output
//
// No publishAs: this leaf's testkit is consumed inside the leaf and is not offered to the
// composition root, unlike the JPA one whose ArchUnit rule pack the root applies to the production
// graph. Publishing is opt-in in the convention precisely so that difference stays a decision.
testkitPublisher {
consumedBy 'test'
}
dependencies {
@@ -113,85 +100,62 @@ tasks.named('test', Test) {
}
}
tasks.register('mongoReplicaSetTest', Test) {
group = 'verification'
description = 'Single-node replica set contract lane: mapping, atomic write, transaction, ' +
'change stream (design §29).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-replicaset' }
applyMongoImageSelection(it)
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
// Six lanes, declared rather than assembled. `ca.strict-test-lane` owns testClassesDirs, classpath,
// tag selection, failOnNoDiscoveredTests and the up-to-date refusal — the five lines that used to be
// copied once per lane here and again in four other leaves.
strictTestLanes {
lane('mongoReplicaSetTest') {
tag = 'mongodb-replicaset'
description = 'Single-node replica set contract lane: mapping, atomic write, transaction, ' +
'change stream (design §29).'
customize = { test -> applyMongoImageSelection(test) }
}
lane('mongoFailoverTest') {
tag = 'mongodb-failover'
description = 'Three-node replica set failover lane: primary kill, partition, unknown ' +
'commit, resume (design §29).'
customize = { test -> applyMongoImageSelection(test) }
}
lane('mongoMigrationTest') {
tag = 'mongodb-migration'
description = 'Migration lane: empty / N-1 / oldest-supported snapshots, lock, checkpoint ' +
'restart (design §12).'
customize = { test -> applyMongoImageSelection(test) }
}
lane('mongoCompatibilityTest') {
tag = 'mongodb-compatibility'
description = 'MongoDB 7.0 compatibility and 8.0 primary certification matrix (design §30).'
customize = { test -> applyMongoImageSelection(test) }
}
lane('mongoSecurityIntegrationTest') {
tag = 'mongodb-security-integration'
description = 'RBAC, TLS, injection and redaction release gate against a real server ' +
'(design §26).'
customize = { test -> applyMongoImageSelection(test) }
}
// Driven by its own source set rather than a tag: for this shape the source set is the
// selection, so the convention asks for no tag.
lane('mongoPerformanceTest') {
sourceSet = 'mongoPerformanceTest'
description = 'Certifies contention, aggregation spill, pagination and pool resource ' +
'bounds (design §29).'
customize = { test ->
applyMongoImageSelection(test)
// Assertions on by default. They defaulted to false, so the lane measured numbers and
// compared them to nothing — a performance gate whose bounds are never evaluated is a
// report, and the release evidence called it a certification.
test.systemProperty 'performance.assertions.enabled',
(project.findProperty('performance.assertions.enabled') ?: 'true').toString()
}
}
}
tasks.register('mongoFailoverTest', Test) {
group = 'verification'
description = 'Three-node replica set failover lane: primary kill, partition, unknown commit, ' +
'resume (design §29).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-failover' }
applyMongoImageSelection(it)
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('mongoMigrationTest', Test) {
group = 'verification'
description = 'Migration lane: empty / N-1 / oldest-supported snapshots, lock, checkpoint ' +
'restart (design §12).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-migration' }
applyMongoImageSelection(it)
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('mongoCompatibilityTest', Test) {
group = 'verification'
description = 'MongoDB 7.0 compatibility and 8.0 primary certification matrix (design §30).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-compatibility' }
applyMongoImageSelection(it)
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('mongoSecurityIntegrationTest', Test) {
group = 'verification'
description = 'RBAC, TLS, injection and redaction release gate against a real server ' +
'(design §26).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-security-integration' }
applyMongoImageSelection(it)
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('mongoPerformanceTest', Test) {
group = 'verification'
description = 'Certifies contention, aggregation spill, pagination and pool resource bounds ' +
'(design §29).'
testClassesDirs = sourceSets.mongoPerformanceTest.output.classesDirs
classpath = sourceSets.mongoPerformanceTest.runtimeClasspath
useJUnitPlatform()
applyMongoImageSelection(it)
// Assertions on by default. They defaulted to false, so the lane measured numbers and compared
// them to nothing — a performance gate whose bounds are never evaluated is a report, and the
// release evidence called it a certification.
systemProperty 'performance.assertions.enabled',
(project.findProperty('performance.assertions.enabled') ?: 'true').toString()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
// `check` gains only the hermetic lanes. The Docker-backed ones stay opt-in for the reason above.
tasks.named('check') {
dependsOn 'mongoStableContractTest', 'verifyMongoTestLaneDisjointness'
dependsOn 'mongoStableContractTest', 'verifyMongoTestLaneDisjointness',
'verifyMongoReleaseContractLanes'
}
// The tag exclusion above is a claim about two task configurations. This checks the claim against
@@ -231,6 +195,55 @@ tasks.register('verifyMongoTestLaneDisjointness') {
}
}
// Splitting the two lanes moved every tagged contract out of `test`, and the release manifest kept
// naming the lane it had left. `MongoReleaseEvidenceVerifier` resolves
// `test-results/<task>/TEST-<className>.xml`, so a contract whose class now runs somewhere else
// resolves to a file that will never exist: the Stable gate reports the transaction retry
// invariants as evidence the run failed to produce, for a suite that ran them.
//
// Checked against the XML the lanes wrote rather than against a tag table, because a tag table here
// would be a second copy of the selection above, and the copy is what drifted the first time.
tasks.register('verifyMongoReleaseContractLanes') {
group = 'verification'
description = 'Fails when a blocking release contract names a lane that did not run its class.'
dependsOn 'test', 'mongoStableContractTest'
def manifest = rootProject.file('../src/config/mongodb/release-contracts.json')
def hermeticLanes = ['test', 'mongoStableContractTest']
def resultsRoot = layout.buildDirectory.dir('test-results')
inputs.file(manifest)
inputs.dir(resultsRoot)
outputs.file(layout.buildDirectory.file('reports/mongo-release-contract-lanes.txt'))
doLast {
def contracts = new groovy.json.JsonSlurper().parse(manifest).contracts
def checked = []
def wrongLane = []
contracts.findAll { hermeticLanes.contains(it.task) }.each { contract ->
def results = resultsRoot.get().dir(contract.task).file(
"TEST-${contract.className}.xml").asFile
if (!results.isFile()) {
wrongLane << "${contract.id} names lane '${contract.task}', which did not run " +
"${contract.className}"
return
}
def suite = new groovy.xml.XmlParser().parse(results)
int executed = (suite.@tests as int) - (suite.@skipped as int)
if (executed < contract.minimumExecuted) {
wrongLane << "${contract.id} requires ${contract.minimumExecuted} executed test(s) " +
"in '${contract.task}' and the lane ran ${executed}"
}
checked << contract.id
}
if (!wrongLane.isEmpty()) {
throw new GradleException(
'the Mongo release manifest points at lanes that cannot produce its evidence: ' +
wrongLane.join('; '))
}
def report = outputs.files.singleFile
report.parentFile.mkdirs()
report.text = "hermetic release contracts verified: ${checked.join(', ')}\n"
}
}
tasks.register('mongoStableContractTest', Test) {
group = 'verification'
description = 'Hermetic stable contract suite: manifests, guardrails, retry scopes, ' +
@@ -257,95 +270,14 @@ tasks.register('mongoStableContractTest', Test) {
// prerequisite for shrinking it: the `api` and `spi` packages are the surface an adopter is meant
// to use, and everything else in this file is a candidate for becoming internal when the leaf is
// split into capability artifacts. Until then the number cannot grow by accident.
def mongoApiSurfaceFile = rootProject.file('../docs/architecture/mongo-api-surface.txt')
Closure<String> renderMongoApiSurface = {
def sourceRoot = file('src/main/java')
def typePattern = ~/(?m)^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(class|interface|enum|record|@interface)\s+(\w+)/
def packagePattern = ~/(?m)^package\s+([\w.]+)\s*;/
List<String> types = []
sourceRoot.eachFileRecurse { candidate ->
if (!candidate.isFile() || !candidate.name.endsWith('.java')) {
return
}
String text = candidate.getText('UTF-8')
def packageMatcher = packagePattern.matcher(text)
if (!packageMatcher.find()) {
return
}
String packageName = packageMatcher.group(1)
def typeMatcher = typePattern.matcher(text)
while (typeMatcher.find()) {
types << "${packageName}.${typeMatcher.group(2)}".toString()
}
}
types = types.unique().toSorted()
String header =
"# MongoDB leaf public API surface — every public top-level type in src/main/java.\n" +
"# A public type in a single-jar leaf is reachable from every adopter's code, so\n" +
"# additions are reviewed rather than discovered. `api` is the intended external\n" +
"# surface; the rest is implementation that has not been moved under an internal\n" +
"# root yet.\n" +
"# Update only after review with:\n" +
"# ./gradlew :adapter:outbound:persistence-mongo:updateMongoApiSurface -PapproveMongoApiSurfaceChange\n" +
"# types: ${types.size()}\n"
header + (types.isEmpty() ? '' : types.join('\n') + '\n')
}
// The approval flag is read at configuration time and carried in, not fetched from `project`
// inside doLast. Task.project at execution time is deprecated and fails under Gradle 10, and it is
// incompatible with the configuration cache — which this build will need before it can adopt one.
boolean mongoApiSurfaceUpdateApproved = project.hasProperty('approveMongoApiSurfaceChange')
tasks.register('verifyMongoApiSurface') {
group = 'verification'
description = 'Fails without mutation when the committed GraphQL public API surface drifts.'
doLast {
if (mongoApiSurfaceUpdateApproved) {
throw new GradleException(
'verifyMongoApiSurface is read-only; use updateMongoApiSurface to record an ' +
'approved change.')
}
String rendered = renderMongoApiSurface()
if (!mongoApiSurfaceFile.isFile()) {
throw new GradleException(
"verifyMongoApiSurface: missing committed baseline ${mongoApiSurfaceFile}")
}
String committed = mongoApiSurfaceFile.getText('UTF-8')
if (committed != rendered) {
List<String> committedTypes = committed.readLines().findAll { !it.startsWith('#') }
List<String> renderedTypes = rendered.readLines().findAll { !it.startsWith('#') }
List<String> added = (renderedTypes - committedTypes).toSorted()
List<String> removed = (committedTypes - renderedTypes).toSorted()
throw new GradleException(
"verifyMongoApiSurface: the public API surface changed.\n" +
(added.isEmpty() ? '' : " added:\n " + added.join('\n ') + '\n') +
(removed.isEmpty() ? '' : " removed:\n " + removed.join('\n ') + '\n') +
"Review the change, then record it with:\n" +
" ./gradlew :adapter:outbound:persistence-mongo:updateMongoApiSurface " +
"-PapproveMongoApiSurfaceChange")
}
logger.lifecycle('verifyMongoApiSurface: OK — the committed public API surface is unchanged.')
}
}
tasks.register('updateMongoApiSurface') {
group = 'verification'
description = 'Rewrites the committed GraphQL public API surface baseline after review.'
doLast {
if (!project.hasProperty('approveMongoApiSurfaceChange')) {
throw new GradleException(
'updateMongoApiSurface requires -PapproveMongoApiSurfaceChange: growing the ' +
'public surface is a review decision, not a build step.')
}
mongoApiSurfaceFile.parentFile.mkdirs()
mongoApiSurfaceFile.setText(renderMongoApiSurface(), 'UTF-8')
logger.lifecycle("updateMongoApiSurface: wrote ${mongoApiSurfaceFile}")
}
}
tasks.named('check') {
dependsOn tasks.named('verifyMongoApiSurface')
apiSurface {
label = 'Mongo'
baseline = rootProject.file('../docs/architecture/mongo-api-surface.txt')
description = 'MongoDB leaf public API surface — every public top-level type in src/main/java.'
rationale = [
'A public type in a single-jar leaf is reachable from every adopter\'s code, so',
'additions are reviewed rather than discovered. `api` is the intended external',
'surface; the rest is implementation that has not been moved under an internal',
'root yet.',
]
}
@@ -23,6 +23,10 @@ public final class MongoAdvancedPromotionGate {
evidence.require("stable-platform");
evidence.require("actual-topology");
evidence.require("security");
// `migration` was in MongoAdvancedPromotionEvidence.REQUIRED and not here, so the gate demanded
// five of the six categories it declares. A promotion could pass with no migration evidence at
// all — which is the shape MNG-008 names: a gate that certifies more than it ran.
evidence.require("migration");
evidence.require("failure");
evidence.require("runbook");
}
@@ -119,6 +119,189 @@ public class MongoPlatformAutoConfiguration {
return new DefaultMongoImperativeExecutor(consistency, collections, translator, observer);
}
/**
* The policies governing which fields a partial update may touch.
*
* <p>Empty unless a composition root declares one, and empty means every atomic and bulk
* operation is refused rather than permitted: a collection whose modifiable fields nobody
* declared has no field a partial update may touch.
*/
@Bean
@ConditionalOnMissingBean
public dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry
mongoAtomicPolicyRegistry() {
return dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry
.empty();
}
/**
* The typed single-document update path.
*
* <p>Declared by no configuration until now. The template, its policy registry and the whole
* {@code AtomicFilter}/{@code AtomicUpdate} algebra shipped in the jar with passing unit tests
* that constructed them directly, so a deployment that switched the platform on had no way to
* reach the one path that enforces the field policy — the design's answer to free-form BSON
* updates was unreachable, and the free-form path was not.
*/
@Bean
@ConditionalOnMissingBean
public dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperationsTemplate
mongoAtomicOperations(
DefaultMongoImperativeExecutor executor,
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry
policies) {
return new dev.caskeleton.adapter.outbound.mongo.imperative.atomic
.MongoAtomicOperationsTemplate(executor, policies);
}
/**
* The batch update path, governed by the same policies as the single-document one.
*
* <p>Same story as the atomic template, with one addition: the bulk executor could be built
* without policies at all, so wiring it without its registry would have re-created the bypass it
* was fixed for.
*/
@Bean
@ConditionalOnMissingBean
public dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkExecutor mongoBulkExecutor(
DefaultMongoImperativeExecutor executor,
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry policies) {
return new dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkExecutor(
executor, policies);
}
/**
* The reactive execution path, when a deployment has a reactive template.
*
* <p>The reactive half shipped and was wired by nothing. {@code DefaultReactiveMongoExecutor},
* the reactive consistency binder and the reactive session factory were beans in no
* configuration, so a deployment that switched the platform on got the blocking path and a set of
* reactive classes that no configuration could construct — including the {@code .timeout(...)}
* translation and the result-budget cursor guard the platform was credited with.
*
* <p>Nested and conditioned on {@code ReactiveMongoTemplate} being present *and* declared as a
* bean. The class is on this leaf's compile classpath either way, so a class condition alone
* would try to build the reactive path in a servlet-only deployment that has no reactive
* template, and fail its startup for a capability it never asked for.
*/
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
@ConditionalOnClass(org.springframework.data.mongodb.core.ReactiveMongoTemplate.class)
@ConditionalOnBean(org.springframework.data.mongodb.core.ReactiveMongoTemplate.class)
public static class ReactiveExecution {
/**
* Binds read and write settings per profile on the reactive template.
*
* @param template the reactive template
* @param registry the consistency registry
* @return the reactive binder
*/
/**
* The driver-facing half of a change stream subscription.
*
* <p>No production code opened a change stream at all. The checkpoint store, the resume
* position, the recovery policy, the invalidate conversion, the ordered pipeline and the
* projector all shipped with passing unit tests and a deployment had no way to run any of them,
* so the at-least-once consumer contract the design describes was unreachable.
*/
@Bean
@ConditionalOnMissingBean
public dev.caskeleton.adapter.outbound.mongo.changestream.consumer.MongoChangeStreamSource
mongoChangeStreamSource(
org.springframework.data.mongodb.core.ReactiveMongoTemplate template,
MongoCollectionProfileRegistry collections) {
return new dev.caskeleton.adapter.outbound.mongo.changestream.consumer
.SpringReactiveChangeStreamSource(template, collections);
}
/**
* One subscription, wired end to end.
*
* <p>Conditional on the pieces only a deployment can supply — what to project, where to record
* that it was projected, how to protect the stored token, and which collection to watch. The
* platform cannot invent a projector, so a consumer that appears without one would be a
* subscription reading a stream into nothing.
*/
@Bean
@ConditionalOnMissingBean
@org.springframework.boot.autoconfigure.condition.ConditionalOnBean({
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription.class,
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore.class,
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeTokenCodec.class,
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjector.class,
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeDeduplicationStore
.class
})
public dev.caskeleton.adapter.outbound.mongo.changestream.consumer
.ReactiveMongoChangeStreamConsumer
reactiveMongoChangeStreamConsumer(
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription
subscription,
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.MongoChangeStreamSource
source,
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore
checkpoints,
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeTokenCodec tokens,
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjector
projector,
dev.caskeleton.adapter.outbound.mongo.changestream.projector
.MongoChangeDeduplicationStore
deduplication) {
return new dev.caskeleton.adapter.outbound.mongo.changestream.consumer
.ReactiveMongoChangeStreamConsumer(
subscription,
source,
checkpoints,
tokens,
new dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamPipeline(
new dev.caskeleton.adapter.outbound.mongo.changestream.projector
.MongoChangeStreamRunner(projector, deduplication, checkpoints)),
new dev.caskeleton.adapter.outbound.mongo.changestream.recovery
.MongoChangeStreamRecoveryPolicy(),
new dev.caskeleton.adapter.outbound.mongo.changestream.recovery
.MongoInvalidateRecovery());
}
@Bean
@ConditionalOnMissingBean
public dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoConsistencyBinder
reactiveMongoConsistencyBinder(
org.springframework.data.mongodb.core.ReactiveMongoTemplate template,
dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyRegistry registry,
org.springframework.context.ApplicationContext applicationContext) {
// The same support contract the imperative binder gets. Without it a document written through
// the reactive executor skips auditing and BeforeConvertCallback while the same document
// written through an ordinary reactive repository does not — two documents, one mapping.
return new dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoConsistencyBinder(
template,
registry,
dev.caskeleton.adapter.outbound.mongo.imperative.MongoTemplateSupportContract.inContext(
applicationContext));
}
/**
* The single reactive execution path.
*
* @param consistency the reactive binder
* @param collections the collection profile registry
* @param translator the failure translator
* @param observer the operation observer
* @return the reactive executor
*/
@Bean
@ConditionalOnMissingBean
public dev.caskeleton.adapter.outbound.mongo.reactive.DefaultReactiveMongoExecutor
mongoReactiveExecutor(
dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoConsistencyBinder
consistency,
MongoCollectionProfileRegistry collections,
MongoFailureTranslator translator,
MongoOperationObserver observer) {
return new dev.caskeleton.adapter.outbound.mongo.reactive.DefaultReactiveMongoExecutor(
consistency, collections, translator, observer);
}
}
/** Lets a caller tighten a registered budget, never loosen it. */
@Bean
@ConditionalOnMissingBean
@@ -244,12 +427,17 @@ public class MongoPlatformAutoConfiguration {
*
* <p>Registered as a bean so an application's existing actuator policy can expose it. Whether it
* is exposed stays that policy's decision; being constructible only in tests was not a policy.
*
* <p>The secondary count comes from the probe and the requirement from the settings. This method
* used to pass a literal zero for the first and let the indicator default the second, so the
* health detail an operator reads said "0 of 2 secondaries, degraded" on every replica set the
* platform ever ran against, healthy or not — a fixed answer presented as a measurement.
*/
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(MongoTopologyProbe.class)
public MongoPlatformHealthIndicator mongoPlatformHealthIndicator(
MongoTopologyProbe probe, MongoPlatformSettings properties) {
return new MongoPlatformHealthIndicator(probe, properties, 0);
return new MongoPlatformHealthIndicator(probe, properties);
}
}
@@ -22,38 +22,19 @@ public final class MongoPlatformHealthIndicator {
private final MongoPlatformSettings properties;
private final int availableSecondaries;
private final int requiredSecondaries;
/** The default requirement: a replica set that can still elect and still acknowledge majority. */
public static final int DEFAULT_REQUIRED_SECONDARIES = 2;
public MongoPlatformHealthIndicator(
MongoTopologyProbe probe, MongoPlatformSettings properties, int availableSecondaries) {
this(probe, properties, availableSecondaries, DEFAULT_REQUIRED_SECONDARIES);
}
/**
* A health view with an explicit secondary requirement.
* A health view over one probe and the settings that say what this deployment needs.
*
* <p>The threshold used to be the literal {@code 2} for every non-standalone deployment. A
* three-member set with one arbiter has one secondary and is healthy; a five-member set with two
* secondaries is one failure away from losing majority writes and reported as fine. One constant
* cannot describe both, so the number is configuration and the deployment states it.
* <p>Both numbers behind {@link #degradedSecondaryAvailability()} arrive through those two, and
* neither is a parameter of this constructor, because both used to be supplied at the call site
* and both were wrong there. The observed count was a literal {@code 0} in the bean method, so
* every replica set reported every secondary missing; the requirement was a literal {@code 2} for
* every deployment, so a three-member set with an arbiter — one secondary, and healthy — was
* reported degraded for as long as it ran.
*/
public MongoPlatformHealthIndicator(
MongoTopologyProbe probe,
MongoPlatformSettings properties,
int availableSecondaries,
int requiredSecondaries) {
public MongoPlatformHealthIndicator(MongoTopologyProbe probe, MongoPlatformSettings properties) {
this.probe = Objects.requireNonNull(probe, "probe");
this.properties = Objects.requireNonNull(properties, "properties");
this.availableSecondaries = availableSecondaries;
if (requiredSecondaries < 0) {
throw new IllegalArgumentException("the required secondary count must not be negative");
}
this.requiredSecondaries = requiredSecondaries;
}
/**
@@ -87,7 +68,7 @@ public final class MongoPlatformHealthIndicator {
*/
public boolean degradedSecondaryAvailability() {
return probe.observedTopology() != MongoTopology.STANDALONE
&& availableSecondaries < requiredSecondaries;
&& probe.availableSecondaries() < properties.requiredSecondaries();
}
/** The health detail map, containing only bounded, non-sensitive values. */
@@ -97,8 +78,8 @@ public final class MongoPlatformHealthIndicator {
details.put("serverVersion", probe.serverVersion());
details.put("topologyMismatch", topologyMismatch());
details.put("degradedSecondaryAvailability", degradedSecondaryAvailability());
details.put("availableSecondaries", availableSecondaries);
details.put("requiredSecondaries", requiredSecondaries);
details.put("availableSecondaries", probe.availableSecondaries());
details.put("requiredSecondaries", properties.requiredSecondaries());
details.put("profiles", properties.profiles().keySet());
return Map.copyOf(details);
}
@@ -25,7 +25,20 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
*/
@ConfigurationProperties("ca-skeleton.persistence-mongo.platform")
public record MongoPlatformSettings(
Map<String, MongoProfileProperties> profiles, boolean transactions, boolean changeStreams) {
Map<String, MongoProfileProperties> profiles,
boolean transactions,
boolean changeStreams,
Integer requiredSecondaries) {
/**
* The secondary count assumed when a deployment states none.
*
* <p>Two, because that is the smallest number that survives losing one and still acknowledges a
* majority write. It is a starting point and not a description: a three-member set with an
* arbiter has one secondary and is healthy, and a five-member set with two is one failure from
* losing majority writes. Both need the deployment to say so.
*/
public static final int DEFAULT_REQUIRED_SECONDARIES = 2;
public MongoPlatformSettings {
// Absent rather than empty is the normal case: a deployment that has opted the module in but
@@ -40,11 +53,22 @@ public record MongoPlatformSettings(
// zero beans, zero threads, and a `true` that cannot be honoured never becomes one that looks
// honoured.
changeStreams = false;
// Absent means the platform default, and a negative count is refused rather than clamped: a
// requirement of -1 is not a lenient requirement, it is a typo, and reading it as "no
// secondaries needed" is how a health view comes to report a cluster it never checked.
if (requiredSecondaries == null) {
requiredSecondaries = DEFAULT_REQUIRED_SECONDARIES;
}
if (requiredSecondaries < 0) {
throw MongoOperationRejectedException.of(
"config.secondaries",
"the required secondary count must not be negative; " + requiredSecondaries + " is");
}
}
/** An empty configuration, for a deployment that has not opted the platform in. */
public static MongoPlatformSettings empty() {
return new MongoPlatformSettings(Map.of(), false, false);
return new MongoPlatformSettings(Map.of(), false, false, null);
}
/**
@@ -1,71 +0,0 @@
package dev.caskeleton.adapter.outbound.mongo.autoconfigure;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
/**
* The evidence categories a Stable release must produce (design §30, Task 50).
*
* <p>Named categories rather than "all tests pass", because a suite can be green and still be
* missing a lane. Requiring a category to be present makes the absence of failover or compatibility
* evidence a failure rather than a silence.
*/
public record MongoStableReleaseEvidence(Set<String> categories) {
/** Every category the Stable gate requires. */
public static final Set<String> REQUIRED =
Set.of(
"mapping",
"transaction",
"migration",
"change-stream",
"security",
"failover",
"performance",
"compatibility");
public MongoStableReleaseEvidence {
Objects.requireNonNull(categories, "categories");
categories = Set.copyOf(categories);
}
/** Evidence with nothing recorded. */
public static MongoStableReleaseEvidence empty() {
return new MongoStableReleaseEvidence(Set.of());
}
/** The evidence a complete release run produces. */
public static MongoStableReleaseEvidence complete() {
return new MongoStableReleaseEvidence(REQUIRED);
}
/** Returns a copy with one more category recorded. */
public MongoStableReleaseEvidence with(String category) {
Set<String> updated = new LinkedHashSet<>(categories);
updated.add(Objects.requireNonNull(category, "category"));
return new MongoStableReleaseEvidence(updated);
}
/**
* Asserts one category is present.
*
* @throws IllegalStateException naming the missing category
*/
public void require(String category) {
if (!categories.contains(Objects.requireNonNull(category, "category"))) {
throw new IllegalStateException(
"the MongoDB Stable release gate is missing '"
+ category
+ "' evidence; the required categories are "
+ REQUIRED);
}
}
/** The categories that are still missing. */
public Set<String> missing() {
Set<String> missing = new LinkedHashSet<>(REQUIRED);
missing.removeAll(categories);
return Set.copyOf(missing);
}
}
@@ -1,46 +0,0 @@
package dev.caskeleton.adapter.outbound.mongo.autoconfigure;
import java.util.Objects;
/**
* The single check that decides whether the Stable platform may ship (design §30, Task 50).
*
* <p>Every category is required, including the two teams most often skip. Compatibility, because it
* only fails for the customers still on the older server. Failover, because it only fails during an
* election — which is exactly when nobody is reading test reports.
*/
public final class MongoStableReleaseGate {
/**
* Verifies a release run.
*
* @throws IllegalStateException naming the first missing category
*/
public void verify(MongoStableReleaseEvidence evidence) {
Objects.requireNonNull(evidence, "evidence");
evidence.require("mapping");
evidence.require("transaction");
evidence.require("migration");
evidence.require("change-stream");
evidence.require("security");
evidence.require("failover");
evidence.require("performance");
evidence.require("compatibility");
}
/** True when the release run produced every required category. */
public boolean passes(MongoStableReleaseEvidence evidence) {
return Objects.requireNonNull(evidence, "evidence").missing().isEmpty();
}
/**
* Whether an Advanced capability may be part of this release.
*
* <p>Always false. Advanced capabilities have their own promotion gate with their own evidence,
* and folding them into the Stable gate would mean shipping something whose real topology was
* never exercised.
*/
public boolean includesAdvancedCapabilities() {
return false;
}
}
@@ -16,6 +16,10 @@ import java.util.Objects;
* actually connected to. The startup validator compares the two, because "we configured a replica
* set" and "we are talking to one" are different claims and only the second one determines whether
* transactions work.
*
* <p>Everything here is an observation. Nothing on this class has a default, because a default is
* an answer about a cluster nobody looked at, and the health view that reads these values reports
* them to an operator as fact.
*/
public final class MongoTopologyProbe {
@@ -23,9 +27,21 @@ public final class MongoTopologyProbe {
private final String serverVersion;
public MongoTopologyProbe(MongoTopology observedTopology, String serverVersion) {
private final int availableSecondaries;
/**
* @param observedTopology the topology the client connected to
* @param serverVersion the version the connected server reports
* @param availableSecondaries how many data-bearing secondaries the client can currently see
*/
public MongoTopologyProbe(
MongoTopology observedTopology, String serverVersion, int availableSecondaries) {
this.observedTopology = Objects.requireNonNull(observedTopology, "observedTopology");
this.serverVersion = Objects.requireNonNull(serverVersion, "serverVersion");
if (availableSecondaries < 0) {
throw new IllegalArgumentException("the observed secondary count must not be negative");
}
this.availableSecondaries = availableSecondaries;
}
/** The topology this client is actually connected to. */
@@ -38,6 +54,18 @@ public final class MongoTopologyProbe {
return serverVersion;
}
/**
* How many data-bearing secondaries this client can currently see.
*
* <p>Part of the probe rather than a number the health bean is handed separately, because it is
* an observation of the same cluster description that produced the topology, and a caller who has
* one has the other. It used to be neither: the health bean was constructed with a literal zero,
* so a healthy five-member set and a set with every secondary down produced identical output.
*/
public int availableSecondaries() {
return availableSecondaries;
}
/** The capabilities this topology and version support. */
public MongoCapabilitySet capabilities() {
List<MongoCapabilitySupport> supports = new ArrayList<>();
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.outbound.mongo.changestream;
import org.bson.BsonDocument;
/**
* Turns a driver resume token into a durable checkpoint and back (design §20.2).
*
* <p>A resume token encodes the cluster time and the document key of the last event, so it is
* production write timing and production identifiers in one opaque-looking string. The checkpoint
* holds it as ciphertext for that reason, and this is the boundary where the conversion happens:
* without it, a consumer wanting to resume would have to decode the stored bytes itself, which is
* how "the checkpoint is encrypted" quietly becomes "the checkpoint is base64".
*
* <p>There is no default implementation. How a deployment protects the token — which key, which
* rotation — is not something this leaf can decide on its behalf, and a built-in that merely
* encoded would be worse than none: it would satisfy the type and none of the reason for it.
*/
public interface MongoResumeTokenCodec {
/**
* Stores a token as a checkpoint.
*
* @param subscriptionProfile the subscription the position belongs to
* @param token the driver's resume token
* @param position which resume option the token must be replayed with
* @return the durable checkpoint
*/
MongoResumeCheckpoint encode(
String subscriptionProfile, BsonDocument token, MongoResumePosition position);
/**
* Recovers the token a checkpoint stands for.
*
* @param checkpoint the stored checkpoint
* @return the driver's resume token
*/
BsonDocument decode(MongoResumeCheckpoint checkpoint);
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.outbound.mongo.changestream.consumer;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition;
import java.util.Optional;
import org.bson.BsonDocument;
import org.springframework.data.mongodb.core.ChangeStreamEvent;
import org.springframework.data.mongodb.core.query.Query;
import reactor.core.publisher.Flux;
/**
* Opens the driver's change stream at a stated position.
*
* <p>A seam rather than a call, because everything worth testing about a change stream consumer —
* what it resumes from, what it does with an invalidate, what it does when history is lost — is
* about the sequence around the stream and not about the stream itself. Testing that sequence
* against a live replica set turns a lifecycle assertion into a container.
*/
@FunctionalInterface
public interface MongoChangeStreamSource {
/**
* Opens the stream.
*
* @param subscription the subscription's configuration
* @param resumeFrom the position to resume from, absent when starting fresh
* @return the events, in the order the server produced them
*/
Flux<ChangeStreamEvent<org.bson.Document>> open(
MongoChangeStreamSubscription subscription, Optional<ResumeFrom> resumeFrom);
/**
* A stored position and the option it must be replayed with.
*
* <p>The two travel together because they are not independent: {@code resumeAfter} refuses a
* token that came from an invalidate event, so a position without its option cannot be replayed
* in exactly the case where replaying matters.
*
* @param token the driver's resume token
* @param position the resume option the token was recorded under
*/
record ResumeFrom(BsonDocument token, MongoResumePosition position) {
public ResumeFrom {
java.util.Objects.requireNonNull(token, "token");
java.util.Objects.requireNonNull(position, "position");
}
}
/** The query a subscription watches; unfiltered unless a deployment narrows it. */
static Query everything() {
return new Query();
}
}
@@ -0,0 +1,221 @@
package dev.caskeleton.adapter.outbound.mongo.changestream.consumer;
import com.mongodb.client.model.changestream.OperationType;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamPipeline;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamState;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoClusterTime;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeTokenCodec;
import dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjectionResult;
import dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryDecision;
import dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryPolicy;
import dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoInvalidateRecovery;
import dev.caskeleton.adapter.outbound.mongo.failure.MongoDriverFailureView;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import org.bson.BsonDocument;
import org.springframework.data.mongodb.core.ChangeStreamEvent;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* One change stream subscription, from stored position to stored position (design §20).
*
* <p>Every part of this lifecycle existed and nothing joined them. The checkpoint store, the resume
* position, the recovery policy, the invalidate conversion, the ordered pipeline and the projector
* were separate classes with passing unit tests, and no production code opened a change stream at
* all — so the at-least-once consumer contract the design describes was a set of components a
* deployment had no way to run. The pieces were correct and the capability did not exist.
*
* <p>What this owns is the order: load the checkpoint, choose the resume option it was recorded
* under, open the stream there, hand the events to the pipeline one at a time, and store the new
* position only after a projection has succeeded. An invalidate is converted to a {@code
* startAfter} position at the moment it arrives, because afterwards the distinction cannot be
* recovered. A lost history stops the subscription rather than restarting it from now, which would
* produce a projection that is missing an unknown range of changes and reports itself as healthy.
*/
public final class ReactiveMongoChangeStreamConsumer {
private final MongoChangeStreamSubscription subscription;
private final MongoChangeStreamSource source;
private final MongoResumeCheckpointStore checkpoints;
private final MongoResumeTokenCodec tokens;
private final MongoChangeStreamPipeline pipeline;
private final MongoChangeStreamRecoveryPolicy recovery;
private final MongoInvalidateRecovery invalidates;
private final AtomicReference<MongoChangeStreamState> state =
new AtomicReference<>(MongoChangeStreamState.STOPPED);
private final AtomicReference<String> requiredRunbook = new AtomicReference<>("");
public ReactiveMongoChangeStreamConsumer(
MongoChangeStreamSubscription subscription,
MongoChangeStreamSource source,
MongoResumeCheckpointStore checkpoints,
MongoResumeTokenCodec tokens,
MongoChangeStreamPipeline pipeline,
MongoChangeStreamRecoveryPolicy recovery,
MongoInvalidateRecovery invalidates) {
this.subscription = Objects.requireNonNull(subscription, "subscription");
this.source = Objects.requireNonNull(source, "source");
this.checkpoints = Objects.requireNonNull(checkpoints, "checkpoints");
this.tokens = Objects.requireNonNull(tokens, "tokens");
this.pipeline = Objects.requireNonNull(pipeline, "pipeline");
this.recovery = Objects.requireNonNull(recovery, "recovery");
this.invalidates = Objects.requireNonNull(invalidates, "invalidates");
}
/**
* Runs the subscription until it is stopped or a failure the platform will not resume from.
*
* @return the projection result of every event this consumer handled
*/
public Flux<MongoChangeProjectionResult> run() {
return Mono.fromRunnable(() -> state.set(MongoChangeStreamState.STARTING))
.thenMany(Flux.defer(this::openAndConsume))
.onErrorResume(this::recoverFrom);
}
/** The subscription's current lifecycle state. */
public MongoChangeStreamState state() {
return state.get();
}
/** The runbook an operator needs when this subscription has stopped, empty while it runs. */
public String requiredRunbook() {
return requiredRunbook.get();
}
private Flux<MongoChangeProjectionResult> openAndConsume() {
return checkpoints
.load(subscription.subscriptionProfile())
.map(this::resumeFrom)
.flatMapMany(
resumeFrom -> {
state.set(MongoChangeStreamState.RUNNING);
return pipeline.process(source.open(subscription, resumeFrom).flatMap(this::toEvent));
});
}
private Optional<MongoChangeStreamSource.ResumeFrom> resumeFrom(
Optional<MongoResumeCheckpoint> stored) {
return stored.map(
checkpoint -> {
// The option the position was recorded under, not the option this call happens to want:
// replaying an invalidate token with resumeAfter is refused by the server, and the
// failure it produces names neither the subscription nor the reason.
invalidates.requireCorrectResumeOption(checkpoint, checkpoint.position());
return new MongoChangeStreamSource.ResumeFrom(
tokens.decode(checkpoint), checkpoint.position());
});
}
private Mono<MongoChangeStreamPipeline.MongoChangeEvent> toEvent(
ChangeStreamEvent<org.bson.Document> event) {
BsonDocument token =
event.getResumeToken() == null ? null : event.getResumeToken().asDocument();
if (token == null) {
return Mono.error(
new IllegalStateException(
"a change event arrived without a resume token; there is no position to store and"
+ " continuing would advance the subscription past an event it cannot resume at"));
}
// An invalidate closes the stream, and its token is the one thing `resumeAfter` refuses. The
// option is decided here, while the event says which it is; by restart time the stored token
// looks like any other and the distinction is gone.
MongoResumePosition position =
event.getOperationType() == OperationType.INVALIDATE
? MongoResumePosition.START_AFTER
: MongoResumePosition.RESUME_AFTER;
MongoResumeCheckpoint checkpoint =
tokens.encode(subscription.subscriptionProfile(), token, position);
return Mono.just(
new MongoChangeStreamPipeline.MongoChangeEvent(
identityOf(event), rawOf(event), checkpoint, clusterTimeOf(event)));
}
private MongoChangeEventIdentity identityOf(ChangeStreamEvent<org.bson.Document> event) {
var raw = event.getRaw();
String namespace =
(event.getDatabaseName() == null ? "" : event.getDatabaseName())
+ '.'
+ (event.getCollectionName() == null ? "" : event.getCollectionName());
String documentKey =
raw == null || raw.getDocumentKey() == null ? "" : raw.getDocumentKey().toJson();
// The transaction coordinates are what separate two updates to one document inside one
// transaction: without them the two events share cluster time, namespace, key and operation,
// and the second is discarded as a redelivery of the first.
String withinTransaction =
raw == null || raw.getTxnNumber() == null
? ""
: raw.getTxnNumber().getValue()
+ ":"
+ (raw.getLsid() == null ? "" : raw.getLsid().toJson());
return MongoChangeEventIdentity.of(
clusterTimeOf(event).toString(),
namespace,
documentKey,
event.getOperationType() == null ? "unknown" : event.getOperationType().getValue(),
withinTransaction);
}
private static BsonDocument rawOf(ChangeStreamEvent<org.bson.Document> event) {
var raw = event.getRaw();
return raw == null || raw.getFullDocument() == null
? new BsonDocument()
: raw.getFullDocument().toBsonDocument();
}
private static MongoClusterTime clusterTimeOf(ChangeStreamEvent<org.bson.Document> event) {
var timestamp = event.getBsonTimestamp();
return timestamp == null
? new MongoClusterTime(0, 0)
: new MongoClusterTime(timestamp.getTime(), timestamp.getInc());
}
private Flux<MongoChangeProjectionResult> recoverFrom(Throwable failure) {
MongoChangeStreamRecoveryDecision decision = decisionFor(failure);
if (!decision.autoResume()) {
state.set(decision.state());
requiredRunbook.set(decision.requiredRunbook());
return Flux.error(failure);
}
state.set(MongoChangeStreamState.RESUMING);
// Reopened from the stored checkpoint rather than from where the stream stopped: the last
// position that was written is the last event a projection completed for, and anything after
// it must be delivered again.
return Flux.defer(this::openAndConsume).onErrorResume(this::haltOn);
}
private Flux<MongoChangeProjectionResult> haltOn(Throwable failure) {
MongoChangeStreamRecoveryDecision decision = decisionFor(failure);
state.set(decision.autoResume() ? MongoChangeStreamState.FAILED : decision.state());
requiredRunbook.set(
decision.requiredRunbook().isBlank()
? MongoChangeStreamRecoveryPolicy.FAILURE_RUNBOOK
: decision.requiredRunbook());
return Flux.error(failure);
}
private MongoChangeStreamRecoveryDecision decisionFor(Throwable failure) {
if (failure instanceof com.mongodb.MongoException driverFailure) {
return recovery.onFailure(MongoDriverFailureView.from(driverFailure));
}
// Anything that is not a driver failure came from the projection or the checkpoint store, and
// resuming a stream does not fix either.
return MongoChangeStreamRecoveryDecision.halt(
MongoChangeStreamState.FAILED, MongoChangeStreamRecoveryPolicy.FAILURE_RUNBOOK);
}
}
@@ -0,0 +1,71 @@
package dev.caskeleton.adapter.outbound.mongo.changestream.consumer;
import com.mongodb.client.model.changestream.FullDocument;
import com.mongodb.client.model.changestream.FullDocumentBeforeChange;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition;
import dev.caskeleton.adapter.outbound.mongo.imperative.MongoCollectionProfileRegistry;
import java.util.Objects;
import java.util.Optional;
import org.springframework.data.mongodb.core.ChangeStreamEvent;
import org.springframework.data.mongodb.core.ChangeStreamOptions;
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
import reactor.core.publisher.Flux;
/**
* The driver-facing half of a change stream subscription.
*
* <p>Deliberately thin: it turns a stored position into the driver's options and opens the cursor.
* Everything that decides what happens next lives in the consumer, so the lifecycle can be
* exercised without a replica set and this class has nothing left to get wrong except the mapping
* it is named for.
*
* <p>The collection is resolved through the profile registry rather than named directly, so a
* subscription cannot watch a collection the platform does not know about — which is also the only
* way the physical name stays a deployment decision.
*/
public final class SpringReactiveChangeStreamSource implements MongoChangeStreamSource {
private final ReactiveMongoTemplate template;
private final MongoCollectionProfileRegistry collections;
public SpringReactiveChangeStreamSource(
ReactiveMongoTemplate template, MongoCollectionProfileRegistry collections) {
this.template = Objects.requireNonNull(template, "template");
this.collections = Objects.requireNonNull(collections, "collections");
}
@Override
public Flux<ChangeStreamEvent<org.bson.Document>> open(
MongoChangeStreamSubscription subscription, Optional<ResumeFrom> resumeFrom) {
Objects.requireNonNull(subscription, "subscription");
Objects.requireNonNull(resumeFrom, "resumeFrom");
return template
.changeStream(org.bson.Document.class)
.watchCollection(collections.require(subscription.collectionProfile()))
.resumeAt(optionsFor(subscription, resumeFrom))
.listen();
}
private static ChangeStreamOptions optionsFor(
MongoChangeStreamSubscription subscription, Optional<ResumeFrom> resumeFrom) {
ChangeStreamOptions.ChangeStreamOptionsBuilder options =
ChangeStreamOptions.builder()
.fullDocumentLookup(FullDocument.UPDATE_LOOKUP)
.fullDocumentBeforeChangeLookup(
subscription.fullDocumentBeforeChange()
? FullDocumentBeforeChange.WHEN_AVAILABLE
: FullDocumentBeforeChange.OFF);
resumeFrom.ifPresent(
from -> {
if (from.position() == MongoResumePosition.START_AFTER) {
options.startAfter(from.token());
} else {
options.resumeAfter(from.token());
}
});
return options.build();
}
}
@@ -59,7 +59,20 @@ public final class DefaultMongoFailureTranslator implements MongoFailureTranslat
Objects.requireNonNull(elapsed, "elapsed");
Objects.requireNonNull(failure, "failure");
MongoFailureClassification classification = classifier.classify(failure);
// The operation type and the phase, not just the failure.
//
// This method already held the operation type and called the context-free overload, so the
// distinction the phase-aware classifier exists to make — a lost response on a FIND is a
// repeatable read, the same loss on an UPDATE is a write of unknown outcome — was discarded on
// every non-transaction path. Both executors translate through here, so that was every ordinary
// operation: a failed read was reported as an ambiguous write.
//
// The phase is derived from what the driver saw rather than passed in, because that is the only
// evidence available outside a transaction: nothing sent is a server-selection failure, sent
// without a response is the ambiguous window, and a received response means the command
// completed and the failure is in its content.
MongoFailureClassification classification =
classifier.classify(operationType, phaseOf(failure), failure);
MongoFailureContext failureContext =
new MongoFailureContext(
MongoOperationScope.of(context),
@@ -103,4 +116,21 @@ public final class DefaultMongoFailureTranslator implements MongoFailureTranslat
new MongoUnclassifiedFailureException(failureContext);
};
}
/**
* The phase a driver failure reports about itself.
*
* @param failure the narrowed driver view
* @return the phase the evidence supports
*/
private static MongoFailurePhase phaseOf(MongoDriverFailureView failure) {
if (!failure.commandWasSent()) {
// Conservative on purpose: `commandWasSent` is already conservative, so a false here means
// the driver is sure nothing left, which is the one case that is safely retryable.
return MongoFailurePhase.SERVER_SELECTION;
}
return failure.responseWasReceived()
? MongoFailurePhase.RESPONSE_WAIT
: MongoFailurePhase.COMMAND_SEND;
}
}
@@ -1,5 +1,6 @@
package dev.caskeleton.adapter.outbound.mongo.imperative;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@@ -14,6 +15,15 @@ import org.springframework.data.mongodb.core.query.Update;
* <p>Every method passes the collection this scope was built with. That is the whole implementation
* and the whole point: a caller cannot supply a different one, so the registered collection profile
* and any tenant boundary derived from it hold without the caller having to cooperate.
*
* <p>Every query-shaped method also carries the operation's deadline as {@code maxTimeMS}, and that
* is the difference between a deadline and a report about one. The blocking executor could only
* measure elapsed time after the callback returned — a Java callback cannot be interrupted mid
* driver call — so an operation that ran past its budget was detected, never stopped. Sent to the
* server, the same number ends the work.
*
* <p>It applies to the methods that take a {@link Query} or an {@link Aggregation}, because those
* are the ones the server can cut short. {@code insert} carries no query to attach it to.
*/
final class BoundScopedOperations implements ScopedMongoOperations {
@@ -21,9 +31,26 @@ final class BoundScopedOperations implements ScopedMongoOperations {
private final MongoOperations operations;
BoundScopedOperations(String collection, MongoOperations operations) {
private final Duration timeout;
BoundScopedOperations(String collection, MongoOperations operations, Duration timeout) {
this.collection = Objects.requireNonNull(collection, "collection");
this.operations = Objects.requireNonNull(operations, "operations");
this.timeout = Objects.requireNonNull(timeout, "timeout");
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("the operation timeout must be positive and finite");
}
}
/**
* Attaches the operation deadline to a query.
*
* <p>Mutates the caller's query rather than copying it, which is what Spring Data's own fluent
* API does; a copy would silently drop any hint, collation or read preference the caller set.
*/
private Query bounded(Query query) {
Objects.requireNonNull(query, "query");
return query.maxTimeMsec(timeout.toMillis());
}
@Override
@@ -33,17 +60,17 @@ final class BoundScopedOperations implements ScopedMongoOperations {
@Override
public <T> Optional<T> findOne(Query query, Class<T> documentType) {
return Optional.ofNullable(operations.findOne(query, documentType, collection));
return Optional.ofNullable(operations.findOne(bounded(query), documentType, collection));
}
@Override
public <T> List<T> findMany(Query query, Class<T> documentType) {
return operations.find(query, documentType, collection);
return operations.find(bounded(query), documentType, collection);
}
@Override
public long count(Query query, Class<?> documentType) {
return operations.count(query, documentType, collection);
return operations.count(bounded(query), documentType, collection);
}
@Override
@@ -53,16 +80,23 @@ final class BoundScopedOperations implements ScopedMongoOperations {
@Override
public long updateOne(Query query, Update update, Class<?> documentType) {
return operations.updateFirst(query, update, documentType, collection).getModifiedCount();
return operations
.updateFirst(bounded(query), update, documentType, collection)
.getModifiedCount();
}
@Override
public long deleteOne(Query query, Class<?> documentType) {
return operations.remove(query, documentType, collection).getDeletedCount();
return operations.remove(bounded(query), documentType, collection).getDeletedCount();
}
@Override
public <T> List<T> aggregate(Aggregation aggregation, Class<T> resultType) {
return operations.aggregate(aggregation, collection, resultType).getMappedResults();
Aggregation bounded =
aggregation.withOptions(
org.springframework.data.mongodb.core.aggregation.AggregationOptions.builder()
.maxTime(timeout)
.build());
return operations.aggregate(bounded, collection, resultType).getMappedResults();
}
}
@@ -80,7 +80,7 @@ public final class DefaultMongoImperativeExecutor implements MongoImperativeExec
String physicalCollection = collections.require(context.collectionProfile());
MongoOperations operations = consistency.templateFor(context.consistency());
ScopedAccess access = new ScopedAccess(physicalCollection, operations);
ScopedAccess access = new ScopedAccess(physicalCollection, operations, context.timeout());
long startedAt = System.nanoTime();
try (MongoOperationObservation observation = observer.start(context, operationType)) {
@@ -151,9 +151,13 @@ public final class DefaultMongoImperativeExecutor implements MongoImperativeExec
private final MongoOperations operations;
private ScopedAccess(String physicalCollection, MongoOperations operations) {
private final java.time.Duration timeout;
private ScopedAccess(
String physicalCollection, MongoOperations operations, java.time.Duration timeout) {
this.physicalCollection = physicalCollection;
this.operations = operations;
this.timeout = timeout;
}
@Override
@@ -178,7 +182,10 @@ public final class DefaultMongoImperativeExecutor implements MongoImperativeExec
@Override
public ScopedMongoOperations scoped() {
return new BoundScopedOperations(physicalCollection, operations);
// The deadline travels with the scope. The executor can only measure elapsed time after the
// callback returns; the scoped operations send the same number to the server as maxTimeMS,
// where it actually ends the work.
return new BoundScopedOperations(physicalCollection, operations, timeout);
}
@Override
@@ -44,6 +44,28 @@ public record MongoTemplateSupportContract(
null, WriteResultChecking.NONE, true, Objects.requireNonNull(context, "context"));
}
/**
* Applies this contract to a derived reactive template.
*
* <p>The reactive binder derived its templates with read preference and write concern only, so a
* document written through the reactive executor skipped auditing and {@code
* BeforeConvertCallback} while the same document written through an ordinary reactive repository
* did not. The imperative half was fixed and the reactive half was left, which stopped being
* theoretical when the reactive execution path was wired.
*
* @param template the derived reactive template
*/
public void applyTo(org.springframework.data.mongodb.core.ReactiveMongoTemplate template) {
Objects.requireNonNull(template, "template");
if (writeConcernResolver != null) {
template.setWriteConcernResolver(writeConcernResolver);
}
template.setWriteResultChecking(
writeResultChecking == null ? WriteResultChecking.NONE : writeResultChecking);
template.setEntityLifecycleEventsEnabled(entityLifecycleEventsEnabled);
context().ifPresent(template::setApplicationContext);
}
/** Applies this contract to a derived template. */
public void applyTo(MongoTemplate template) {
Objects.requireNonNull(template, "template");
@@ -1,11 +1,8 @@
package dev.caskeleton.adapter.outbound.mongo.imperative.atomic;
import dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import dev.caskeleton.adapter.outbound.mongo.imperative.DefaultMongoImperativeExecutor;
import java.util.Map;
import java.util.Objects;
import org.springframework.data.mongodb.core.FindAndModifyOptions;
import org.springframework.data.mongodb.core.MongoOperations;
@@ -25,13 +22,12 @@ public final class MongoAtomicOperationsTemplate implements MongoAtomicOperation
private final DefaultMongoImperativeExecutor executor;
private final Map<CollectionProfileName, MongoAtomicPolicy> policies;
private final MongoAtomicPolicyRegistry policies;
public MongoAtomicOperationsTemplate(
DefaultMongoImperativeExecutor executor,
Map<CollectionProfileName, MongoAtomicPolicy> policies) {
DefaultMongoImperativeExecutor executor, MongoAtomicPolicyRegistry policies) {
this.executor = Objects.requireNonNull(executor, "executor");
this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies"));
this.policies = Objects.requireNonNull(policies, "policies");
}
@Override
@@ -67,7 +63,7 @@ public final class MongoAtomicOperationsTemplate implements MongoAtomicOperation
Objects.requireNonNull(update, "update");
Objects.requireNonNull(returnMode, "returnMode");
MongoAtomicPolicy policy = requirePolicy(context.collectionProfile());
MongoAtomicPolicy policy = policies.require(context.collectionProfile());
policy.requireFilter(filter);
policy.requireUpdate(update);
@@ -141,14 +137,4 @@ public final class MongoAtomicOperationsTemplate implements MongoAtomicOperation
}
return AtomicUpdateResult.applied(result.getMatchedCount(), result.getModifiedCount(), null);
}
private MongoAtomicPolicy requirePolicy(CollectionProfileName profile) {
MongoAtomicPolicy policy = policies.get(profile);
if (policy == null) {
throw MongoOperationRejectedException.of(
"atomic.policy",
"collection profile '" + profile + "' has no registered atomic update policy");
}
return policy;
}
}
@@ -0,0 +1,107 @@
package dev.caskeleton.adapter.outbound.mongo.imperative.atomic;
import dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* Which atomic update policy governs each collection profile.
*
* <p>The policies used to be a bare {@code Map} passed to whichever object happened to be
* constructed with them, which is why the single-document path and the batch path could disagree
* about them: the atomic template refused an operation on a profile with no policy, and the bulk
* executor was constructible with an empty map and silently validated nothing. Putting the map
* behind one type with one lookup is what makes "the same policy governs both paths" a property of
* the code rather than of the wiring.
*
* <p>A Spring context cannot inject a {@code Map} keyed by anything other than bean names, so this
* is also the shape that lets a composition root contribute policies as a single bean.
*/
public final class MongoAtomicPolicyRegistry {
private final Map<CollectionProfileName, MongoAtomicPolicy> policies;
private MongoAtomicPolicyRegistry(Map<CollectionProfileName, MongoAtomicPolicy> policies) {
this.policies = policies;
}
/** Starts a registry declaration. */
public static Builder builder() {
return new Builder();
}
/**
* A registry a deployment has declared nothing in.
*
* <p>Empty means every atomic and bulk operation is refused, which is the safe reading: a
* collection whose modifiable fields nobody declared has no field a partial update may touch.
*
* @return a registry with no registered profile
*/
public static MongoAtomicPolicyRegistry empty() {
return new MongoAtomicPolicyRegistry(Map.of());
}
/**
* Returns the policy governing a profile.
*
* @param profile the collection profile
* @return the registered policy
* @throws MongoOperationRejectedException when the profile has no registered policy
*/
public MongoAtomicPolicy require(CollectionProfileName profile) {
Objects.requireNonNull(profile, "profile");
MongoAtomicPolicy policy = policies.get(profile);
if (policy == null) {
throw MongoOperationRejectedException.of(
"atomic.policy",
"collection profile '" + profile + "' has no registered atomic update policy");
}
return policy;
}
/**
* Reports whether a profile has a registered policy.
*
* @param profile the collection profile
* @return true when a policy is registered
*/
public boolean isRegistered(CollectionProfileName profile) {
return profile != null && policies.containsKey(profile);
}
/** Collects the policies of one deployment. */
public static final class Builder {
private final Map<CollectionProfileName, MongoAtomicPolicy> policies = new LinkedHashMap<>();
private Builder() {}
/**
* Registers the policy governing one collection profile.
*
* @param profile the collection profile
* @param policy the policy governing it
* @return this builder
*/
public Builder register(CollectionProfileName profile, MongoAtomicPolicy policy) {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(policy, "policy");
if (policies.putIfAbsent(profile, policy) != null) {
// Two declarations for one profile means one of them is not in force and the reader cannot
// tell which. A refused startup is cheaper than a field that is protected in the document
// one developer read and writable through the policy the context happened to keep.
throw new IllegalArgumentException(
"collection profile '" + profile + "' already has a registered atomic update policy");
}
return this;
}
/** Builds the immutable registry. */
public MongoAtomicPolicyRegistry build() {
return new MongoAtomicPolicyRegistry(Map.copyOf(policies));
}
}
}
@@ -6,6 +6,8 @@ import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureCategory;
import dev.caskeleton.adapter.outbound.mongo.imperative.DefaultMongoImperativeExecutor;
import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy;
import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -26,31 +28,22 @@ public final class MongoBulkExecutor {
private final DefaultMongoImperativeExecutor executor;
private final java.util.Map<
dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName,
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy>
policies;
public MongoBulkExecutor(DefaultMongoImperativeExecutor executor) {
this(executor, java.util.Map.of());
}
private final MongoAtomicPolicyRegistry policies;
/**
* A bulk executor that enforces the same field and operator policy the atomic path enforces.
*
* <p>The single-document path validated every filter and update against the collection's policy;
* the bulk path did not look at it. Protected fields and unregistered operators were therefore
* reachable by putting the same update inside a batch — the policy held for one document and not
* for a thousand.
* the bulk path did not look at it, and its policies were an optional constructor argument that
* defaulted to none. Protected fields and unregistered operators were therefore reachable by
* putting the same update inside a batch — the policy held for one document and not for a
* thousand. There is one constructor now because an executor that can be built without policies
* is how that bypass survives a review of the validation code itself.
*/
public MongoBulkExecutor(
DefaultMongoImperativeExecutor executor,
java.util.Map<
dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName,
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy>
policies) {
DefaultMongoImperativeExecutor executor, MongoAtomicPolicyRegistry policies) {
this.executor = Objects.requireNonNull(executor, "executor");
this.policies = java.util.Map.copyOf(Objects.requireNonNull(policies, "policies"));
this.policies = Objects.requireNonNull(policies, "policies");
}
/** Runs one bulk plan against the operation's collection. */
@@ -58,13 +51,13 @@ public final class MongoBulkExecutor {
Objects.requireNonNull(context, "context");
Objects.requireNonNull(plan, "plan");
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy policy =
policies.get(context.collectionProfile());
if (policy != null) {
for (MongoBulkWritePlan.MongoBulkItem item : plan.items()) {
policy.requireFilter(item.filter());
policy.requireUpdate(item.update());
}
// Refused here, before anything is sent, and refused for a profile with no policy at all —
// the same reading the single-document path applies. A batch is validated as a whole because
// an ordered batch that fails halfway leaves the earlier items applied.
MongoAtomicPolicy policy = policies.require(context.collectionProfile());
for (MongoBulkWritePlan.MongoBulkItem item : plan.items()) {
policy.requireFilter(item.filter());
policy.requireUpdate(item.update());
}
return executor
@@ -45,16 +45,48 @@ public class MongoMappingConfiguration {
return MongoCustomConversionsFactory.forManifest(manifest);
}
/** Refuses implicit, JVM-zone-dependent {@code LocalDateTime} storage. */
/**
* Refuses implicit, JVM-zone-dependent {@code LocalDateTime} storage.
*
* <p>Built from the converters this deployment actually registered, not from an empty set. The
* guard used to be constructed {@code withoutConverters()} and then asked to validate the
* manifest, so it compared a declaration against nothing: it could only ever reject {@code
* LOCAL_DATE_TIME_WITH_REGISTERED_CONVERTER}, and a deployment that *did* register the named
* converter was rejected exactly as loudly as one that did not. The check that exists to
* distinguish those two cases could not tell them apart.
*
* @param manifest the frozen representation manifest
* @param conversions the converters Boot assembled for this deployment
* @return the guard
*/
@Bean
@ConditionalOnMissingBean
public LocalDateTimeMappingGuard localDateTimeMappingGuard(
MongoTypeRepresentationManifest manifest) {
LocalDateTimeMappingGuard guard = LocalDateTimeMappingGuard.withoutConverters();
MongoTypeRepresentationManifest manifest,
org.springframework.data.mongodb.core.convert.MongoCustomConversions conversions) {
LocalDateTimeMappingGuard guard =
new LocalDateTimeMappingGuard(registeredTemporalConverterNames(conversions));
guard.validate(manifest);
return guard;
}
/**
* The temporal converter names this deployment registered.
*
* <p>Spring's {@code MongoCustomConversions} does not expose its converter list, so presence is
* asked the way the mapping layer itself asks it: can a {@code LocalDateTime} be written as a
* BSON date. A deployment that registered the named converter answers yes; one that did not
* answers no, and that is the whole distinction the guard needs.
*/
private static java.util.Set<String> registeredTemporalConverterNames(
org.springframework.data.mongodb.core.convert.MongoCustomConversions conversions) {
boolean localDateTimeIsConverted =
conversions.hasCustomWriteTarget(java.time.LocalDateTime.class);
return localDateTimeIsConverted
? java.util.Set.of(LocalDateTimeMappingGuard.REQUIRED_CONVERTER)
: java.util.Set.of();
}
/**
* Applies the per-collection type metadata policy to the live converter.
*

Some files were not shown because too many files have changed in this diff Show More