feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가
This commit is contained in:
@@ -72,6 +72,15 @@ Package root: `dev.caskeleton.application`.
|
||||
| `messaging.event.ValidatedIntegrationEvent` | Stable semantic identities plus immutable exact encoded bytes and hashes, ready for a later durable append boundary. |
|
||||
| `messaging.event.IntegrationEventEncoderPort` | Framework-free local draft-to-validated-event boundary implemented by an outbound adapter. |
|
||||
|
||||
## Notification canonical namespace (NOTIF-ADR-005)
|
||||
|
||||
`..notification.platform..` is canonical. The R1 types directly under
|
||||
`dev.caskeleton.application.notification` remain and accept no new production consumers; their
|
||||
per-type disposition is in `docs/notification/module-mapping.md`. Production dependencies between
|
||||
the two namespaces are zero, enforced by
|
||||
`NOTIFICATION_R1_AND_PLATFORM_DO_NOT_DEPEND_ON_EACH_OTHER`; the only permitted exception is
|
||||
`dev.caskeleton.application.notification.compatibility.r1`.
|
||||
|
||||
## Notification R1 application boundary
|
||||
|
||||
- `dev.caskeleton.application.notification` owns only framework-free semantic values, code-owned
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.TenantId;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Operator authority check.
|
||||
*
|
||||
* <p>It lives in application-core. It decides who may act on what — business policy by any
|
||||
* definition — and it sat in the outbound adapter, which is the layer that talks to providers. An
|
||||
* authorization rule there is a rule no application test can reach and no architecture rule about
|
||||
* application policy can see.
|
||||
*
|
||||
* <p>Application authority never grants an operator authority. The two planes are separated so that
|
||||
* a compromised application credential cannot redrive a message or lift a suppression — the actions
|
||||
* whose whole purpose is to override the platform's own safety decisions.
|
||||
*/
|
||||
public final class AdminAuthorizationGuard {
|
||||
|
||||
/** Require an authority, or refuse. */
|
||||
public void require(AdminActor actor, NotificationAdminAuthority authority) {
|
||||
Objects.requireNonNull(actor, "actor");
|
||||
Objects.requireNonNull(authority, "authority");
|
||||
if (!actor.holds(authority)) {
|
||||
throw new AdminAccessDeniedException(authority);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require that the actor may act on a tenant.
|
||||
*
|
||||
* <p>An actor with no tenant is a global operator; one bound to a tenant may only act inside it.
|
||||
*/
|
||||
public void requireTenant(AdminActor actor, TenantId tenantId) {
|
||||
Objects.requireNonNull(actor, "actor");
|
||||
Objects.requireNonNull(tenantId, "tenantId");
|
||||
Optional<TenantId> scope = actor.tenantId();
|
||||
if (scope.isPresent() && !scope.get().equals(tenantId)) {
|
||||
throw new AdminAccessDeniedException(NotificationAdminAuthority.SUPPRESS);
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* What an admin operation id claim decided.
|
||||
*
|
||||
* <p>Four outcomes, because collapsing them loses the distinction that matters. A replay must
|
||||
* return the earlier result without acting; an in-progress claim must not act and must not pretend
|
||||
* the work is done; and the same id with a different command is a mistake worth reporting rather
|
||||
* than an idempotent repeat.
|
||||
*
|
||||
* @param outcome what the caller may do
|
||||
* @param existing the earlier result, when there is one
|
||||
*/
|
||||
public record AdminOperationClaim(Outcome outcome, Optional<AdminOperationResult> existing) {
|
||||
|
||||
/** The four answers a claim can give. */
|
||||
public enum Outcome {
|
||||
|
||||
/** This caller holds the operation and may proceed. */
|
||||
CLAIMED,
|
||||
|
||||
/** The operation already completed; {@code existing} is what it produced. */
|
||||
REPLAY,
|
||||
|
||||
/** Another caller holds it right now. Nothing has been decided yet. */
|
||||
IN_PROGRESS,
|
||||
|
||||
/** The same id was presented with a different command. */
|
||||
CONFLICT
|
||||
}
|
||||
|
||||
/** Validates the claim. */
|
||||
public AdminOperationClaim {
|
||||
Objects.requireNonNull(outcome, "outcome");
|
||||
Objects.requireNonNull(existing, "existing");
|
||||
if (outcome == Outcome.REPLAY && existing.isEmpty()) {
|
||||
throw new IllegalArgumentException("a replay must carry the result it is replaying");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller may run the operation.
|
||||
*
|
||||
* @return the claim
|
||||
*/
|
||||
public static AdminOperationClaim claimed() {
|
||||
return new AdminOperationClaim(Outcome.CLAIMED, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* The operation already completed.
|
||||
*
|
||||
* @param existing what it produced
|
||||
* @return the claim
|
||||
*/
|
||||
public static AdminOperationClaim replay(AdminOperationResult existing) {
|
||||
return new AdminOperationClaim(Outcome.REPLAY, Optional.of(existing));
|
||||
}
|
||||
|
||||
/**
|
||||
* Another caller holds the operation.
|
||||
*
|
||||
* @return the claim
|
||||
*/
|
||||
public static AdminOperationClaim inProgress() {
|
||||
return new AdminOperationClaim(Outcome.IN_PROGRESS, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* The id was reused with a different command.
|
||||
*
|
||||
* @return the claim
|
||||
*/
|
||||
public static AdminOperationClaim conflict() {
|
||||
return new AdminOperationClaim(Outcome.CONFLICT, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the caller may perform the operation.
|
||||
*
|
||||
* @return true only for a fresh claim
|
||||
*/
|
||||
public boolean mayProceed() {
|
||||
return outcome == Outcome.CLAIMED;
|
||||
}
|
||||
}
|
||||
+20
@@ -10,4 +10,24 @@ public interface AdminOperationStorePort {
|
||||
|
||||
/** Store the result of an operation. */
|
||||
AdminOperationResult save(AdminOperationResult result, AdminActor actor, String action);
|
||||
|
||||
/**
|
||||
* Claims an operation id before anything happens, atomically.
|
||||
*
|
||||
* <p>The admin path was find, then side effect, then save. Two callers presenting the same
|
||||
* operation id both read "not found", both ran the redrive, and both saved — the idempotency key
|
||||
* was checked but never held, so it prevented a repeat and not a race. A destructive operation
|
||||
* run twice concurrently is the exact failure the id exists to stop.
|
||||
*
|
||||
* <p>The fingerprint is part of the claim: the same id with a different command is not a replay,
|
||||
* it is a conflict, and answering it with the earlier result would silently execute neither.
|
||||
*
|
||||
* @param operationId the caller's idempotency key
|
||||
* @param commandFingerprint a digest of the command this id was presented with
|
||||
* @param actor who is asking
|
||||
* @param action what they are asking for
|
||||
* @return whether this caller may proceed, and what the earlier one did if not
|
||||
*/
|
||||
AdminOperationClaim claim(
|
||||
String operationId, String commandFingerprint, AdminActor actor, String action);
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
|
||||
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Blocks an unapproved redrive of an ambiguous attempt.
|
||||
*
|
||||
* <p>Moved out of the outbound adapter: whether a human must approve a re-send is a decision about
|
||||
* the product, not about a provider protocol.
|
||||
*
|
||||
* <p>The platform cannot tell whether the first submission reached the user, so re-sending is a
|
||||
* decision with a real cost that only a human can accept. Requiring the approval flag makes that
|
||||
* acceptance an explicit, audited act rather than a default.
|
||||
*/
|
||||
public final class DuplicateRiskGuard {
|
||||
|
||||
/** Verify the operator accepted the duplicate risk when one exists. */
|
||||
public void verify(DeliveryAttemptSnapshot attempt, boolean approved) {
|
||||
Objects.requireNonNull(attempt, "attempt");
|
||||
boolean risky =
|
||||
attempt.confirmation() == AttemptConfirmation.AMBIGUOUS
|
||||
|| attempt.submissionOutcome()
|
||||
== dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome
|
||||
.CONFIRMED_ACCEPTED;
|
||||
if (risky && !approved) {
|
||||
throw new DuplicateRiskApprovalRequiredException();
|
||||
}
|
||||
}
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
|
||||
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.ProviderConfigurationException;
|
||||
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.ReconciliationService;
|
||||
import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent;
|
||||
import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort;
|
||||
import dev.caskeleton.application.notification.platform.policy.SuppressionEntry;
|
||||
import dev.caskeleton.application.notification.platform.policy.SuppressionId;
|
||||
import dev.caskeleton.application.notification.platform.policy.SuppressionSource;
|
||||
import dev.caskeleton.application.notification.platform.policy.SuppressionStorePort;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* N4 operator plane.
|
||||
*
|
||||
* <p>It lived in the outbound notification adapter, where it performed operator authorization,
|
||||
* tenant scoping, duplicate-risk approval, idempotency and transaction orchestration — every one of
|
||||
* them a decision about what the product allows, in the layer whose job is speaking provider
|
||||
* protocols. What remains out there is {@link ProviderRuntimeControlPort}: two operations that turn
|
||||
* an application request into a runtime transition.
|
||||
*
|
||||
* <p>Four properties hold for every operation: a separate authority, an idempotent operation id, a
|
||||
* recorded reason, and an audit row. The idempotency matters more than it looks — an operator
|
||||
* retrying a redrive after a timeout must not send the message twice, which is exactly the failure
|
||||
* the operation is trying to repair.
|
||||
*
|
||||
* <p>A dry run reads and reports but writes nothing, so an operator can see the blast radius of a
|
||||
* bulk action before committing to it.
|
||||
*/
|
||||
public final class NotificationAdminApplicationService implements NotificationAdminService {
|
||||
|
||||
private final AdminAuthorizationGuard authorization;
|
||||
private final DuplicateRiskGuard duplicateRiskGuard;
|
||||
private final DeliveryAttemptStorePort attempts;
|
||||
private final RecipientDeliveryStorePort recipients;
|
||||
private final ReconciliationService reconciliation;
|
||||
private final SuppressionStorePort suppressions;
|
||||
private final ProviderRuntimeControlPort runtimes;
|
||||
private final AdminOperationStorePort operations;
|
||||
private final NotificationAuditPort audit;
|
||||
private final TransactionPort transactions;
|
||||
private final dev.caskeleton.application.notification.platform.dispatch
|
||||
.NotificationIdGeneratorPort
|
||||
ids;
|
||||
private final Clock clock;
|
||||
|
||||
public NotificationAdminApplicationService(
|
||||
AdminAuthorizationGuard authorization,
|
||||
DuplicateRiskGuard duplicateRiskGuard,
|
||||
DeliveryAttemptStorePort attempts,
|
||||
RecipientDeliveryStorePort recipients,
|
||||
ReconciliationService reconciliation,
|
||||
SuppressionStorePort suppressions,
|
||||
ProviderRuntimeControlPort runtimes,
|
||||
AdminOperationStorePort operations,
|
||||
NotificationAuditPort audit,
|
||||
TransactionPort transactions,
|
||||
dev.caskeleton.application.notification.platform.dispatch.NotificationIdGeneratorPort ids,
|
||||
Clock clock) {
|
||||
this.authorization = Objects.requireNonNull(authorization, "authorization");
|
||||
this.duplicateRiskGuard = Objects.requireNonNull(duplicateRiskGuard, "duplicateRiskGuard");
|
||||
this.attempts = Objects.requireNonNull(attempts, "attempts");
|
||||
this.recipients = Objects.requireNonNull(recipients, "recipients");
|
||||
this.reconciliation = Objects.requireNonNull(reconciliation, "reconciliation");
|
||||
this.suppressions = Objects.requireNonNull(suppressions, "suppressions");
|
||||
this.runtimes = Objects.requireNonNull(runtimes, "runtimes");
|
||||
this.operations = Objects.requireNonNull(operations, "operations");
|
||||
this.audit = Objects.requireNonNull(audit, "audit");
|
||||
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||
this.ids = Objects.requireNonNull(ids, "ids");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminOperationResult redrive(RedriveCommand command, AdminActor actor) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
authorization.require(actor, NotificationAdminAuthority.REDRIVE);
|
||||
|
||||
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
||||
if (replayed.isPresent()) {
|
||||
return replayed.get();
|
||||
}
|
||||
|
||||
DeliveryAttemptSnapshot original =
|
||||
attempts
|
||||
.snapshot(command.attemptId())
|
||||
.orElseThrow(() -> new IllegalStateException("delivery attempt is not available"));
|
||||
authorization.requireTenant(actor, original.tenantId());
|
||||
duplicateRiskGuard.verify(original, command.approveDuplicateRisk());
|
||||
|
||||
if (command.dryRun()) {
|
||||
return new AdminOperationResult(
|
||||
command.operationId(),
|
||||
true,
|
||||
1,
|
||||
Optional.of(original.notificationId()),
|
||||
Optional.of(original.recipientDeliveryId()),
|
||||
Optional.empty(),
|
||||
List.of("DRY_RUN"));
|
||||
}
|
||||
|
||||
return transactions.inWrite(
|
||||
() -> {
|
||||
// The logical identities are preserved and only the attempt is new, so the history stays
|
||||
// one story rather than becoming two unrelated notifications.
|
||||
recipients.transition(
|
||||
original.recipientDeliveryId(),
|
||||
RecipientDeliveryState.READY_TO_DISPATCH,
|
||||
Optional.of(clock.instant()));
|
||||
|
||||
AdminOperationResult result =
|
||||
new AdminOperationResult(
|
||||
command.operationId(),
|
||||
false,
|
||||
1,
|
||||
Optional.of(original.notificationId()),
|
||||
Optional.of(original.recipientDeliveryId()),
|
||||
Optional.empty(),
|
||||
List.of(command.reason()));
|
||||
audit.record(
|
||||
new NotificationAuditEvent(
|
||||
"ADMIN_REDRIVE",
|
||||
actor.actorRef(),
|
||||
Optional.of(command.reason()),
|
||||
Optional.of(command.operationId()),
|
||||
clock.instant(),
|
||||
Map.of(
|
||||
"provider", original.providerId().value(),
|
||||
"channel", original.channel().name())));
|
||||
return operations.save(result, actor, "ADMIN_REDRIVE");
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminOperationResult reconcile(ReconcileCommand command, AdminActor actor) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
authorization.require(actor, NotificationAdminAuthority.RECONCILE);
|
||||
|
||||
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
||||
if (replayed.isPresent()) {
|
||||
return replayed.get();
|
||||
}
|
||||
if (command.dryRun()) {
|
||||
return new AdminOperationResult(
|
||||
command.operationId(),
|
||||
true,
|
||||
command.attemptIds().size(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.of("DRY_RUN"));
|
||||
}
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
int reconciled = 0;
|
||||
for (DeliveryAttemptId attemptId : command.attemptIds()) {
|
||||
reconciliation.reconcile(attemptId);
|
||||
reconciled++;
|
||||
}
|
||||
reasons.add(command.reason());
|
||||
|
||||
AdminOperationResult result =
|
||||
new AdminOperationResult(
|
||||
command.operationId(),
|
||||
false,
|
||||
reconciled,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.copyOf(reasons));
|
||||
audit.record(
|
||||
new NotificationAuditEvent(
|
||||
"ADMIN_RECONCILE",
|
||||
actor.actorRef(),
|
||||
Optional.of(command.reason()),
|
||||
Optional.of(command.operationId()),
|
||||
clock.instant(),
|
||||
Map.of()));
|
||||
return operations.save(result, actor, "ADMIN_RECONCILE");
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminOperationResult suppress(SuppressCommand command, AdminActor actor) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
authorization.require(actor, NotificationAdminAuthority.SUPPRESS);
|
||||
authorization.requireTenant(actor, command.tenantId());
|
||||
|
||||
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
||||
if (replayed.isPresent()) {
|
||||
return replayed.get();
|
||||
}
|
||||
if (command.dryRun()) {
|
||||
return new AdminOperationResult(
|
||||
command.operationId(),
|
||||
true,
|
||||
1,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.of("DRY_RUN"));
|
||||
}
|
||||
|
||||
return transactions.inWrite(
|
||||
() -> {
|
||||
int affected;
|
||||
if (command.remove()) {
|
||||
// Removal is by fingerprint match rather than by id, because an operator lifting a
|
||||
// suppression knows the target, not the row identifier the platform assigned.
|
||||
affected =
|
||||
suppressions
|
||||
.activeFor(
|
||||
command.tenantId(), command.targetFingerprint(), clock.instant())
|
||||
.stream()
|
||||
.map(entry -> suppressions.remove(command.tenantId(), entry.id()))
|
||||
.filter(Optional::isPresent)
|
||||
.count()
|
||||
> 0
|
||||
? 1
|
||||
: 0;
|
||||
} else {
|
||||
suppressions.upsert(
|
||||
new SuppressionEntry(
|
||||
// ids.nextId(), not UUID.randomUUID(). The identifier contract requires the
|
||||
// generator port, and while this class lived in the outbound adapter the rule
|
||||
// that says so did not apply to it — a random v4 id here is unordered, so the
|
||||
// suppression table's index degrades the way the platform's own UUIDv7 policy
|
||||
// exists to prevent.
|
||||
new SuppressionId(ids.nextId()),
|
||||
command.tenantId(),
|
||||
command.scope(),
|
||||
command.reason(),
|
||||
command.targetFingerprint(),
|
||||
Optional.empty(),
|
||||
clock.instant(),
|
||||
command.expiresAt(),
|
||||
SuppressionSource.ADMIN));
|
||||
affected = 1;
|
||||
}
|
||||
|
||||
AdminOperationResult result =
|
||||
new AdminOperationResult(
|
||||
command.operationId(),
|
||||
false,
|
||||
affected,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.of(command.reasonText()));
|
||||
audit.record(
|
||||
new NotificationAuditEvent(
|
||||
command.remove() ? "ADMIN_SUPPRESSION_REMOVED" : "ADMIN_SUPPRESSION_ADDED",
|
||||
actor.actorRef(),
|
||||
Optional.of(command.reason().name()),
|
||||
Optional.of(command.operationId()),
|
||||
clock.instant(),
|
||||
Map.of()));
|
||||
return operations.save(
|
||||
result, actor, command.remove() ? "ADMIN_SUPPRESS_REMOVE" : "ADMIN_SUPPRESS_ADD");
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminOperationResult setProviderState(SetProviderStateCommand command, AdminActor actor) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
authorization.require(actor, NotificationAdminAuthority.PROVIDER_CONTROL);
|
||||
|
||||
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
||||
if (replayed.isPresent()) {
|
||||
return replayed.get();
|
||||
}
|
||||
if (command.dryRun()) {
|
||||
return new AdminOperationResult(
|
||||
command.operationId(),
|
||||
true,
|
||||
1,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.of("DRY_RUN"));
|
||||
}
|
||||
|
||||
boolean applied =
|
||||
runtimes.setState(command.profileId(), command.desiredState(), command.reason());
|
||||
if (!applied) {
|
||||
// The transition was refused — an authentication failure is not cleared by declaring the
|
||||
// provider healthy, and a healthy runtime is not degraded on request. Recording success for a
|
||||
// state change that did not happen tells an operator the provider is serving when it is not.
|
||||
throw new ProviderConfigurationException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID,
|
||||
FailureCategory.AUTHORIZATION));
|
||||
}
|
||||
|
||||
AdminOperationResult result =
|
||||
new AdminOperationResult(
|
||||
command.operationId(),
|
||||
false,
|
||||
1,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.of(command.reason()));
|
||||
audit.record(
|
||||
new NotificationAuditEvent(
|
||||
"ADMIN_PROVIDER_STATE",
|
||||
actor.actorRef(),
|
||||
Optional.of(command.reason()),
|
||||
Optional.of(command.operationId()),
|
||||
clock.instant(),
|
||||
Map.of(
|
||||
"providerProfile", command.profileId().value(),
|
||||
"status", command.desiredState().name())));
|
||||
return operations.save(result, actor, "ADMIN_PROVIDER_STATE");
|
||||
}
|
||||
|
||||
/** Current state of a provider runtime, for the health endpoint. */
|
||||
public ProviderRuntimeState providerState(
|
||||
dev.caskeleton.application.notification.platform.api.ProviderProfileId profileId) {
|
||||
return runtimes.state(profileId);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.caskeleton.application.notification.platform.admin;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
|
||||
|
||||
/**
|
||||
* The only thing the admin plane needs from a provider runtime.
|
||||
*
|
||||
* <p>Two operations, because that is all the admin service ever used of {@code
|
||||
* ProviderRuntimeRegistry} — and depending on the registry meant the admin service had to live in
|
||||
* the outbound adapter beside it. That is how operator authorization, tenant scoping,
|
||||
* duplicate-risk approval and transaction orchestration ended up in the layer whose job is talking
|
||||
* to providers.
|
||||
*
|
||||
* <p>Narrow on purpose. A port that exposed the registry would let the next admin operation reach
|
||||
* for rotation, draining or the limiter, and the boundary would erode from the application side
|
||||
* instead of the adapter side.
|
||||
*/
|
||||
public interface ProviderRuntimeControlPort {
|
||||
|
||||
/**
|
||||
* Move a provider profile to an operator-requested state.
|
||||
*
|
||||
* @param profileId which profile
|
||||
* @param desiredState the state the operator asked for
|
||||
* @param reason why, recorded on the runtime
|
||||
* @return whether the runtime accepted the transition; false means it refused, and an
|
||||
* authentication failure is not cleared by declaring a provider healthy
|
||||
*/
|
||||
boolean setState(ProviderProfileId profileId, ProviderRuntimeState desiredState, String reason);
|
||||
|
||||
/**
|
||||
* Current state of a profile.
|
||||
*
|
||||
* @param profileId which profile
|
||||
* @return its state
|
||||
*/
|
||||
ProviderRuntimeState state(ProviderProfileId profileId);
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,12 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
/** How many events an ingestion stored and how many were already known. */
|
||||
/**
|
||||
* How many events an ingestion stored and how many were already known.
|
||||
*
|
||||
* <p>It sits in {@code api} because the inbound port returns it. A port declaring a type owned by
|
||||
* the package that implements the port is a cycle: neither package can then be read, tested, or
|
||||
* moved without the other.
|
||||
*/
|
||||
public record CallbackIngestionResult(int appended, int duplicates) {
|
||||
|
||||
public CallbackIngestionResult {
|
||||
+6
-3
@@ -1,7 +1,5 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
@@ -15,6 +13,11 @@ import java.util.Optional;
|
||||
*
|
||||
* <p>The raw body is kept as bytes because several providers sign the exact octets; decoding first
|
||||
* and re-encoding later is the most common cause of false signature failures.
|
||||
*
|
||||
* <p>It lives in {@code api} rather than {@code callback} because the inbound port declares it.
|
||||
* With it in {@code callback}, the port package that exists to be the boundary depended on the
|
||||
* package implementing behind that boundary, and the two could no longer be reasoned about
|
||||
* separately.
|
||||
*/
|
||||
@SuppressWarnings("ArrayRecordComponent") // defensive copies on construction and on every accessor
|
||||
public record CallbackRequest(
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* One plan, encoded once.
|
||||
*
|
||||
* <p>The fingerprint used to be taken over a canonical string built by {@code RequestFingerprint},
|
||||
* while the stored variables payload was produced separately by a JSON codec. Two encodings of the
|
||||
* same request, written in different places, with nothing forcing them to agree — so the bytes that
|
||||
* decided whether a request was a duplicate were not the bytes the request was stored as.
|
||||
*
|
||||
* <p>These bytes are the request. The fingerprint is their SHA-256 and the persisted payload is
|
||||
* {@link #variablesPayload()} taken from the same pass, so the two cannot drift apart.
|
||||
*
|
||||
* @param version the encoding version, inside the bytes as well as here, so a future change to the
|
||||
* field set produces different fingerprints by construction
|
||||
* @param bytes the canonical, length-framed encoding
|
||||
* @param variablesPayload the variables as they will be stored
|
||||
*/
|
||||
@SuppressWarnings("ArrayRecordComponent") // copied on construction and on every read
|
||||
public record EncodedNotificationPlan(int version, byte[] bytes, String variablesPayload) {
|
||||
|
||||
public EncodedNotificationPlan {
|
||||
Objects.requireNonNull(bytes, "bytes");
|
||||
Objects.requireNonNull(variablesPayload, "variablesPayload");
|
||||
if (version < 1) {
|
||||
throw new IllegalArgumentException("version");
|
||||
}
|
||||
bytes = bytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] bytes() {
|
||||
return bytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
return other instanceof EncodedNotificationPlan encoded
|
||||
&& version == encoded.version
|
||||
&& Arrays.equals(bytes, encoded.bytes)
|
||||
&& variablesPayload.equals(encoded.variablesPayload);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(version, Arrays.hashCode(bytes), variablesPayload);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
// The length, never the content: the encoding contains every variable the caller supplied.
|
||||
return "EncodedNotificationPlan[version=" + version + ", bytes=" + bytes.length + "]";
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
/**
|
||||
* What the platform did with a submission.
|
||||
*
|
||||
* <p>The receipt used to say only "accepted", whatever happened. Deduplication has two distinct
|
||||
* outcomes and a caller has to be able to tell them apart: {@code DROP} means this request will
|
||||
* never be sent, {@code RETURN_EXISTING} means an earlier notification covers it and the id in the
|
||||
* receipt is that earlier one. Reporting both as an ordinary acceptance tells a caller their
|
||||
* notification is on its way when it is not.
|
||||
*/
|
||||
public enum NotificationAcceptance {
|
||||
|
||||
/** A new request, stored and queued. */
|
||||
ACCEPTED,
|
||||
|
||||
/** Suppressed by a deduplication window; nothing will be sent for this submission. */
|
||||
DROPPED_AS_DUPLICATE,
|
||||
|
||||
/** An earlier notification in the same window covers this one; its id is in the receipt. */
|
||||
CONVERGED_ON_EXISTING
|
||||
}
|
||||
+11
-1
@@ -14,13 +14,18 @@ import java.util.Optional;
|
||||
* <p>{@code variables} is the notification-wide normalized variable map. It is pinned into the
|
||||
* request fingerprint together with the template coordinate, so a later template publish cannot
|
||||
* change what an accepted notification means.
|
||||
*
|
||||
* <p>Values are {@link NotificationVariable}, not {@code Object}. With {@code Object} the
|
||||
* fingerprint had to render values through {@code toString()} — so {@code "1"} and {@code 1} hashed
|
||||
* alike and two different requests could collapse onto one idempotency key — and the plan could not
|
||||
* copy a nested mutable value it did not recognise.
|
||||
*/
|
||||
public record NotificationPlan(
|
||||
TenantId tenantId,
|
||||
IdempotencyKey idempotencyKey,
|
||||
String category,
|
||||
TemplateSelection template,
|
||||
Map<String, Object> variables,
|
||||
Map<String, NotificationVariable> variables,
|
||||
List<RecipientSpec> recipients,
|
||||
DeliveryStrategy deliveryStrategy,
|
||||
Optional<Instant> notBefore,
|
||||
@@ -48,6 +53,11 @@ public record NotificationPlan(
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
Objects.requireNonNull(deduplication, "deduplication");
|
||||
Objects.requireNonNull(collapse, "collapse");
|
||||
// Map.copyOf is enough now: NotificationVariable is a closed algebra of immutable records, so
|
||||
// there is no nested mutable object left for a caller to change after the fingerprint has been
|
||||
// computed and before the payload is stored. The map used to be Map<String, Object>, where a
|
||||
// deep copy could only guess at the shapes it was copying and had to leave an unrecognised
|
||||
// mutable type alone.
|
||||
variables = Map.copyOf(Objects.requireNonNull(variables, "variables"));
|
||||
recipients = List.copyOf(Objects.requireNonNull(recipients, "recipients"));
|
||||
boundedMetadata = Map.copyOf(Objects.requireNonNull(boundedMetadata, "boundedMetadata"));
|
||||
|
||||
+28
-1
@@ -10,11 +10,38 @@ import java.util.Objects;
|
||||
* proves the logical request and its recipient jobs are committed, nothing about any provider.
|
||||
*/
|
||||
public record NotificationReceipt(
|
||||
NotificationId notificationId, RequestStatus status, Instant acceptedAt) {
|
||||
NotificationId notificationId,
|
||||
RequestStatus status,
|
||||
Instant acceptedAt,
|
||||
NotificationAcceptance acceptance) {
|
||||
|
||||
public NotificationReceipt {
|
||||
Objects.requireNonNull(notificationId, "notificationId");
|
||||
Objects.requireNonNull(status, "status");
|
||||
Objects.requireNonNull(acceptedAt, "acceptedAt");
|
||||
Objects.requireNonNull(acceptance, "acceptance");
|
||||
}
|
||||
|
||||
/**
|
||||
* A receipt for a request that was accepted and will be sent.
|
||||
*
|
||||
* @param notificationId the notification
|
||||
* @param status its status
|
||||
* @param acceptedAt when it was accepted
|
||||
* @return the receipt
|
||||
*/
|
||||
public static NotificationReceipt accepted(
|
||||
NotificationId notificationId, RequestStatus status, Instant acceptedAt) {
|
||||
return new NotificationReceipt(
|
||||
notificationId, status, acceptedAt, NotificationAcceptance.ACCEPTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request will actually produce a delivery.
|
||||
*
|
||||
* @return false when deduplication dropped or converged it
|
||||
*/
|
||||
public boolean willDeliver() {
|
||||
return acceptance == NotificationAcceptance.ACCEPTED;
|
||||
}
|
||||
}
|
||||
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A template variable value, from a closed set of shapes.
|
||||
*
|
||||
* <p>The plan took {@code Map<String, Object>}. That made three separate problems inevitable and
|
||||
* none of them fixable from the outside:
|
||||
*
|
||||
* <ul>
|
||||
* <li>The fingerprint had to render values with {@code toString()}, so two different values could
|
||||
* produce the same text and two accepted requests could collapse onto one idempotency key.
|
||||
* <li>{@code Object} admits a mutable type the plan cannot copy, so a caller could change a
|
||||
* nested value after the fingerprint was computed and before the payload was encoded — the
|
||||
* stored request then not being the request that was hashed.
|
||||
* <li>The hash and the persisted payload were produced by different code, so nothing forced them
|
||||
* to agree about what the variables were.
|
||||
* </ul>
|
||||
*
|
||||
* <p>A closed algebra makes each value's type part of its identity, gives every shape one canonical
|
||||
* encoding, and makes "unrepresentable" a compile error rather than a runtime surprise.
|
||||
*
|
||||
* <p>{@code toString} is the record default on purpose for the structural cases, but scalars
|
||||
* deliberately do not print their content: a variable is caller data, and caller data belongs in a
|
||||
* notification, not in a log line or an exception message.
|
||||
*/
|
||||
public sealed interface NotificationVariable
|
||||
permits NotificationVariable.TextValue,
|
||||
NotificationVariable.NumberValue,
|
||||
NotificationVariable.BooleanValue,
|
||||
NotificationVariable.NullValue,
|
||||
NotificationVariable.ListValue,
|
||||
NotificationVariable.ObjectValue {
|
||||
|
||||
/** Deepest nesting a variable graph may have. */
|
||||
int MAX_DEPTH = 16;
|
||||
|
||||
/** Most entries one object or list may hold. */
|
||||
int MAX_ENTRIES = 256;
|
||||
|
||||
/** Longest a single text value may be, in UTF-8 bytes. */
|
||||
int MAX_TEXT_BYTES = 8192;
|
||||
|
||||
/** Longest a single key may be, in UTF-8 bytes. */
|
||||
int MAX_KEY_BYTES = 128;
|
||||
|
||||
/**
|
||||
* How deep this value nests, counting itself as one.
|
||||
*
|
||||
* @return the depth
|
||||
*/
|
||||
int depth();
|
||||
|
||||
/** Text. */
|
||||
record TextValue(String value) implements NotificationVariable {
|
||||
|
||||
public TextValue {
|
||||
Objects.requireNonNull(value, "value");
|
||||
if (value.getBytes(StandardCharsets.UTF_8).length > MAX_TEXT_BYTES) {
|
||||
throw new IllegalArgumentException("text variable exceeds " + MAX_TEXT_BYTES + " bytes");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int depth() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
// The length, not the content. A variable can be an order total, a name, or a reset link.
|
||||
return "TextValue[" + value.length() + " chars]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A number, held as {@link BigDecimal}.
|
||||
*
|
||||
* <p>Not {@code double}: {@code 0.1 + 0.2} and {@code 0.3} are the same double and different
|
||||
* decimals, and a fingerprint that cannot tell them apart merges two different requests.
|
||||
*/
|
||||
record NumberValue(BigDecimal value) implements NotificationVariable {
|
||||
|
||||
public NumberValue {
|
||||
Objects.requireNonNull(value, "value");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int depth() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "NumberValue[…]";
|
||||
}
|
||||
}
|
||||
|
||||
/** A boolean. */
|
||||
record BooleanValue(boolean value) implements NotificationVariable {
|
||||
|
||||
@Override
|
||||
public int depth() {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** An explicit absence, distinct from a key that is not present. */
|
||||
record NullValue() implements NotificationVariable {
|
||||
|
||||
/** The single instance. */
|
||||
public static final NullValue INSTANCE = new NullValue();
|
||||
|
||||
@Override
|
||||
public int depth() {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** An ordered list; order is part of the identity. */
|
||||
record ListValue(List<NotificationVariable> values) implements NotificationVariable {
|
||||
|
||||
public ListValue {
|
||||
values = List.copyOf(Objects.requireNonNull(values, "values"));
|
||||
if (values.size() > MAX_ENTRIES) {
|
||||
throw new IllegalArgumentException("list variable exceeds " + MAX_ENTRIES + " entries");
|
||||
}
|
||||
requireWithinDepth(values.stream().mapToInt(NotificationVariable::depth));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int depth() {
|
||||
return 1 + values.stream().mapToInt(NotificationVariable::depth).max().orElse(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** A keyed object; iteration order is normalised at encoding time, not here. */
|
||||
record ObjectValue(Map<String, NotificationVariable> values) implements NotificationVariable {
|
||||
|
||||
public ObjectValue {
|
||||
values = Map.copyOf(Objects.requireNonNull(values, "values"));
|
||||
if (values.size() > MAX_ENTRIES) {
|
||||
throw new IllegalArgumentException("object variable exceeds " + MAX_ENTRIES + " entries");
|
||||
}
|
||||
values.forEach(
|
||||
(key, value) -> {
|
||||
Objects.requireNonNull(key, "variable key");
|
||||
Objects.requireNonNull(value, "variable value");
|
||||
if (key.getBytes(StandardCharsets.UTF_8).length > MAX_KEY_BYTES) {
|
||||
throw new IllegalArgumentException(
|
||||
"variable key exceeds " + MAX_KEY_BYTES + " bytes");
|
||||
}
|
||||
});
|
||||
requireWithinDepth(values.values().stream().mapToInt(NotificationVariable::depth));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int depth() {
|
||||
return 1 + values.values().stream().mapToInt(NotificationVariable::depth).max().orElse(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a graph that nests deeper than the bound.
|
||||
*
|
||||
* <p>Checked while building rather than after, so a caller cannot construct a structure the
|
||||
* encoder would have to walk before discovering it is too deep.
|
||||
*
|
||||
* @param childDepths the depths of the children being wrapped
|
||||
*/
|
||||
private static void requireWithinDepth(java.util.stream.IntStream childDepths) {
|
||||
int deepest = childDepths.max().orElse(0);
|
||||
if (deepest + 1 > MAX_DEPTH) {
|
||||
throw new IllegalArgumentException("variable graph exceeds depth " + MAX_DEPTH);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Text.
|
||||
*
|
||||
* @param value the text
|
||||
* @return the variable
|
||||
*/
|
||||
static NotificationVariable text(String value) {
|
||||
return new TextValue(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A number.
|
||||
*
|
||||
* @param value the number
|
||||
* @return the variable
|
||||
*/
|
||||
static NotificationVariable number(BigDecimal value) {
|
||||
return new NumberValue(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A number from a long.
|
||||
*
|
||||
* @param value the number
|
||||
* @return the variable
|
||||
*/
|
||||
static NotificationVariable number(long value) {
|
||||
return new NumberValue(BigDecimal.valueOf(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* A boolean.
|
||||
*
|
||||
* @param value the flag
|
||||
* @return the variable
|
||||
*/
|
||||
static NotificationVariable bool(boolean value) {
|
||||
return new BooleanValue(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* An explicit null.
|
||||
*
|
||||
* @return the variable
|
||||
*/
|
||||
static NotificationVariable nullValue() {
|
||||
return NullValue.INSTANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* A list.
|
||||
*
|
||||
* @param values the ordered values
|
||||
* @return the variable
|
||||
*/
|
||||
static NotificationVariable list(List<NotificationVariable> values) {
|
||||
return new ListValue(values);
|
||||
}
|
||||
|
||||
/**
|
||||
* An object.
|
||||
*
|
||||
* @param values the keyed values
|
||||
* @return the variable
|
||||
*/
|
||||
static NotificationVariable object(Map<String, NotificationVariable> values) {
|
||||
return new ObjectValue(new LinkedHashMap<>(values));
|
||||
}
|
||||
}
|
||||
+13
@@ -10,4 +10,17 @@ public interface DeliveryProjectionStorePort {
|
||||
|
||||
/** Store a projection for an attempt and roll it up into the recipient and request. */
|
||||
void save(DeliveryAttemptId attemptId, DeliveryProjection projection);
|
||||
|
||||
/**
|
||||
* Claims the right to apply this attempt's suppression side effect, once.
|
||||
*
|
||||
* <p>The ledger is replayed by design — an event that arrives before its attempt is stored
|
||||
* PENDING and projected later, and a failed projection is retried — so "apply the suppression
|
||||
* when the projection says to" ran the side effect again on every replay. The claim is durable
|
||||
* and part of the projection write's transaction, so exactly one caller ever sees true.
|
||||
*
|
||||
* @param attemptId the attempt
|
||||
* @return true when this caller may run the side effect
|
||||
*/
|
||||
boolean claimSuppressionSideEffect(DeliveryAttemptId attemptId);
|
||||
}
|
||||
|
||||
+57
-10
@@ -1,5 +1,7 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.CallbackIngestionResult;
|
||||
import dev.caskeleton.application.notification.platform.api.CallbackRequest;
|
||||
import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
|
||||
@@ -17,38 +19,62 @@ import java.util.Optional;
|
||||
/**
|
||||
* The callback pipeline, in the one order that is safe.
|
||||
*
|
||||
* <p>Limits, then signature, then durable append, then projection. Appending before projecting is
|
||||
* what makes a fast 2xx honest: the provider is told the event is safely recorded, and a projector
|
||||
* defect becomes a replay problem instead of a lost event.
|
||||
* <p>Limits, then signature, then one transactional durable append. Projection is a worker's job,
|
||||
* not this path's: the endpoint promises the provider a fast 2xx once the event is durable, and
|
||||
* projecting inline broke that promise in both directions — a projector that threw failed a
|
||||
* callback the provider had already been told was accepted, and a slow projector held the
|
||||
* provider's connection open while it ran.
|
||||
*
|
||||
* <p>Appending before projecting is what makes a fast 2xx honest: the provider is told the event is
|
||||
* safely recorded, and a projector defect becomes a replay problem instead of a lost event.
|
||||
*/
|
||||
public final class ProviderCallbackIngestionService {
|
||||
@dev.caskeleton.application.capability.UseCaseCapability(
|
||||
transactionMode = dev.caskeleton.application.transaction.TransactionMode.WRITE,
|
||||
idempotency = dev.caskeleton.application.capability.Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = dev.caskeleton.application.capability.RepositoryAccess.WRITE_REPOSITORY,
|
||||
// The ledger append is the write; the callback adapter it consults verifies a signature and may
|
||||
// fetch a provider certificate, which is an outbound call this use case genuinely makes.
|
||||
externalOutboundAllowed = true)
|
||||
@dev.caskeleton.application.security.RequiresPermission("notification-callback:ingest")
|
||||
public final class IngestProviderCallbackApplicationUseCase
|
||||
implements dev.caskeleton.application.notification.platform.port.in
|
||||
.IngestProviderCallbackUseCase {
|
||||
|
||||
private final ProviderCallbackAdapterRegistry adapters;
|
||||
private final ProviderEventLedger ledger;
|
||||
private final ProviderEventProjectionService projection;
|
||||
private final dev.caskeleton.application.transaction.TransactionPort transactions;
|
||||
private final CallbackPayloadProtectionPort payloadProtection;
|
||||
private final NotificationSecurityAuditPort securityAudit;
|
||||
private final NotificationMetricsPort metrics;
|
||||
private final Clock clock;
|
||||
|
||||
public ProviderCallbackIngestionService(
|
||||
public IngestProviderCallbackApplicationUseCase(
|
||||
ProviderCallbackAdapterRegistry adapters,
|
||||
ProviderEventLedger ledger,
|
||||
ProviderEventProjectionService projection,
|
||||
dev.caskeleton.application.transaction.TransactionPort transactions,
|
||||
CallbackPayloadProtectionPort payloadProtection,
|
||||
NotificationSecurityAuditPort securityAudit,
|
||||
NotificationMetricsPort metrics,
|
||||
Clock clock) {
|
||||
this.adapters = Objects.requireNonNull(adapters, "adapters");
|
||||
this.ledger = Objects.requireNonNull(ledger, "ledger");
|
||||
this.projection = Objects.requireNonNull(projection, "projection");
|
||||
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||
this.payloadProtection = Objects.requireNonNull(payloadProtection, "payloadProtection");
|
||||
this.securityAudit = Objects.requireNonNull(securityAudit, "securityAudit");
|
||||
this.metrics = Objects.requireNonNull(metrics, "metrics");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@Override
|
||||
public CallbackIngestionResult handle(
|
||||
dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackCommand
|
||||
command) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
return ingest(command.request());
|
||||
}
|
||||
|
||||
/** Ingest one callback. */
|
||||
@Override
|
||||
public CallbackIngestionResult ingest(CallbackRequest request) {
|
||||
Objects.requireNonNull(request, "request");
|
||||
|
||||
@@ -69,6 +95,19 @@ public final class ProviderCallbackIngestionService {
|
||||
}
|
||||
|
||||
ProviderCallbackAdapter adapter = adapters.require(request.providerProfileId());
|
||||
// The URL names a provider and the path names a profile, and the two are supplied by the
|
||||
// caller. Binding them before verification means a caller cannot present one provider's
|
||||
// callback against another provider's profile and have the platform fetch that provider's
|
||||
// certificate to check it — which is work, and a lookup, done on an unauthenticated request's
|
||||
// say-so.
|
||||
if (!adapter.providerId().equals(request.providerId())) {
|
||||
securityAudit.callbackRejectedByLimit(
|
||||
request.providerProfileId(), "PROVIDER_PROFILE_MISMATCH");
|
||||
throw new CallbackValidationException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.CALLBACK_PAYLOAD_REJECTED,
|
||||
FailureCategory.CALLBACK_VALIDATION_FAILURE));
|
||||
}
|
||||
CallbackVerificationResult verification = adapter.verify(request);
|
||||
if (!verification.valid()) {
|
||||
// A rejected signature is a security event, not a provider event: writing it to the ledger
|
||||
@@ -102,8 +141,16 @@ public final class ProviderCallbackIngestionService {
|
||||
payloadProtection.fingerprint(request.providerProfileId(), event, rawDigest)));
|
||||
}
|
||||
|
||||
AppendEventResult append = ledger.appendAll(events);
|
||||
append.newEvents().forEach(projection::project);
|
||||
// One write transaction around the whole batch. Appending event by event let a failure part
|
||||
// way through commit the events before it and lose the rest, and the caller was told nothing
|
||||
// about the split.
|
||||
AppendEventResult append = transactions.inWrite(() -> ledger.appendAll(events));
|
||||
|
||||
// Deliberately not projected here. The endpoint's contract is "durable append, then a fast
|
||||
// 2xx", and projecting synchronously broke it in both directions: a projector that threw
|
||||
// failed the callback the provider had already been promised was accepted, and a slow
|
||||
// projector held the provider's connection open. The events are PENDING and
|
||||
// ProviderEventReplayWorker drains them.
|
||||
|
||||
metrics.increment(
|
||||
NotificationMetricName.CALLBACK,
|
||||
+1
@@ -1,5 +1,6 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.CallbackRequest;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
+27
@@ -28,6 +28,33 @@ public interface ProviderEventLedger {
|
||||
/** Events that could not be matched to an attempt yet. */
|
||||
List<ProviderEventRecord> unmatched(int limit);
|
||||
|
||||
/**
|
||||
* Binds events that arrived before the attempt they belong to.
|
||||
*
|
||||
* <p>A provider callback routinely reaches this platform before the submitting transaction has
|
||||
* written the provider request id — the provider answers faster than the caller commits. Such an
|
||||
* event is stored with no attempt, and until now nothing ever went back for it: the repository
|
||||
* had a {@code bindAttemptIfUnbound} statement with no caller, so a callback-before-outcome event
|
||||
* stayed unmatched permanently and its delivery was never confirmed.
|
||||
*
|
||||
* <p>The identifier is passed in clear and hashed by the implementation. Only the hash is stored
|
||||
* — the ledger never keeps a provider's request id in clear text — and hashing is the adapter's
|
||||
* concern, not this contract's.
|
||||
*
|
||||
* <p>Scoped to the profile as well as the hash. The hash is of a provider's own request id, and
|
||||
* two providers — or two profiles of one provider — can mint the same id; binding on the hash
|
||||
* alone would attach one tenant's callback to another tenant's attempt.
|
||||
*
|
||||
* @param providerProfileId the profile whose attempt this is
|
||||
* @param providerRequestId the provider's own request id, as the attempt received it
|
||||
* @param attemptId the attempt to bind to
|
||||
* @return the events this call bound, which are the ones to project
|
||||
*/
|
||||
List<ProviderEventRecord> bindUnmatched(
|
||||
dev.caskeleton.application.notification.platform.api.ProviderProfileId providerProfileId,
|
||||
String providerRequestId,
|
||||
dev.caskeleton.application.notification.platform.api.DeliveryAttemptId attemptId);
|
||||
|
||||
/** Every stored event for one attempt, oldest first, for projection replay. */
|
||||
List<ProviderEventRecord> eventsForAttempt(
|
||||
dev.caskeleton.application.notification.platform.api.DeliveryAttemptId attemptId);
|
||||
|
||||
+5
-1
@@ -66,7 +66,11 @@ public final class ProviderEventProjectionService {
|
||||
ProjectionResult result = projector.get().project(attempt.get(), event, current);
|
||||
if (result.changed()) {
|
||||
projections.save(attempt.get().attemptId(), result.projection());
|
||||
if (result.projection().suppressionFacts().requiresSuppression()) {
|
||||
// Claimed, not merely conditioned on the facts. The ledger is replayed by design,
|
||||
// so "the projection says suppressed" is true on every replay and the side effect
|
||||
// ran each time.
|
||||
if (result.projection().suppressionFacts().requiresSuppression()
|
||||
&& projections.claimSuppressionSideEffect(attempt.get().attemptId())) {
|
||||
sideEffects.apply(attempt.get(), result.projection().suppressionFacts());
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -1,5 +1,6 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.CallbackRequest;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
|
||||
+26
-1
@@ -11,6 +11,31 @@ public sealed interface ContactPointValue
|
||||
/** Physical kind, used as uniqueness scope and encryption associated data. */
|
||||
ContactPointType type();
|
||||
|
||||
/** Canonical form used for the keyed lookup fingerprint. */
|
||||
/**
|
||||
* Canonical form used for the keyed lookup fingerprint.
|
||||
*
|
||||
* <p>Identity only. For Web Push that is the endpoint and the client public key — the two fields
|
||||
* that decide whether two subscriptions are the same one. It is deliberately <em>not</em> the
|
||||
* form that gets encrypted: a fingerprint over a secret would make the lookup index a place to
|
||||
* test guesses against.
|
||||
*/
|
||||
String normalized();
|
||||
|
||||
/**
|
||||
* Complete form, encrypted at rest.
|
||||
*
|
||||
* <p>Separate from {@link #normalized()} because the two answer different questions, and
|
||||
* conflating them silently destroyed data. The protector encrypted {@code normalized()}, so a Web
|
||||
* Push subscription came back from storage with its auth secret replaced by sixteen zero bytes
|
||||
* and its VAPID key id replaced by the literal {@code "restored"}. An RFC 8291 payload built from
|
||||
* that cannot be decrypted by the browser it was addressed to, and no test noticed because both
|
||||
* halves of the round trip used the same lossy form.
|
||||
*
|
||||
* <p>Versioned, so a future field can be added without making every stored row unreadable.
|
||||
*
|
||||
* @return the versioned serialized form
|
||||
*/
|
||||
default String serialized() {
|
||||
return "v1:" + normalized();
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -75,11 +75,29 @@ public record WebPushSubscriptionValue(
|
||||
|
||||
@Override
|
||||
public String normalized() {
|
||||
// Identity: the endpoint and the client public key are what make two subscriptions the same
|
||||
// one. The auth secret is deliberately absent — a lookup fingerprint over it would turn the
|
||||
// index into an oracle for guessing it.
|
||||
return endpoint.toString()
|
||||
+ "|"
|
||||
+ Base64.getUrlEncoder().withoutPadding().encodeToString(p256dh);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String serialized() {
|
||||
// All four fields. Encrypting normalized() lost the auth secret and the VAPID key id, and the
|
||||
// reveal path invented replacements for both — sixteen zero bytes and the literal "restored" —
|
||||
// so a stored subscription could never produce a payload its browser could decrypt.
|
||||
Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
|
||||
return String.join(
|
||||
"|",
|
||||
"v1",
|
||||
endpoint.toString(),
|
||||
encoder.encodeToString(p256dh),
|
||||
encoder.encodeToString(authSecret),
|
||||
vapidKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.capability.Idempotency;
|
||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||
import dev.caskeleton.application.notification.platform.api.CancelResult;
|
||||
import dev.caskeleton.application.notification.platform.port.in.CancelNotificationCommand;
|
||||
import dev.caskeleton.application.notification.platform.port.in.CancelNotificationUseCase;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.application.transaction.TransactionMode;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Stopping future logical attempts.
|
||||
*
|
||||
* <p>Its own class, so its capability is its own. A single class implementing submit, schedule,
|
||||
* cancel and get cannot declare one honest transaction mode — the compiler says so too, because
|
||||
* {@code CommandUseCase} cannot be inherited twice with different type arguments. The mandatory
|
||||
* fitness gate reads these annotations; an orchestration that never implements the marker is simply
|
||||
* not checked, which is how this entrypoint bypassed it.
|
||||
*/
|
||||
@UseCaseCapability(
|
||||
transactionMode = TransactionMode.WRITE,
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||
@RequiresPermission("notification:cancel")
|
||||
public final class CancelNotificationApplicationUseCase implements CancelNotificationUseCase {
|
||||
|
||||
private final NotificationSubmissionService service;
|
||||
private final TransactionPort transactions;
|
||||
|
||||
public CancelNotificationApplicationUseCase(
|
||||
NotificationSubmissionService service, TransactionPort transactions) {
|
||||
this.service = Objects.requireNonNull(service, "service");
|
||||
// Injected, and called directly below. A use case that declares it owns a repository-backed
|
||||
// transaction and then delegates the boundary to a collaborator is making a claim the
|
||||
// architecture gate cannot verify and a reader cannot trust.
|
||||
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||
}
|
||||
|
||||
@Override
|
||||
public CancelResult handle(CancelNotificationCommand command) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
return transactions.inWrite(
|
||||
() -> service.cancelInTransaction(command.notificationId(), command.command()));
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+34
-12
@@ -22,21 +22,36 @@ public final class CanonicalNotificationPlanWriter {
|
||||
|
||||
private final NotificationIdGeneratorPort ids;
|
||||
private final NotificationRoutePlannerPort routePlanner;
|
||||
private final NotificationVariablesCodecPort variables;
|
||||
|
||||
public CanonicalNotificationPlanWriter(
|
||||
NotificationIdGeneratorPort ids,
|
||||
NotificationRoutePlannerPort routePlanner,
|
||||
NotificationVariablesCodecPort variables) {
|
||||
NotificationIdGeneratorPort ids, NotificationRoutePlannerPort routePlanner) {
|
||||
this.ids = Objects.requireNonNull(ids, "ids");
|
||||
this.routePlanner = Objects.requireNonNull(routePlanner, "routePlanner");
|
||||
this.variables = Objects.requireNonNull(variables, "variables");
|
||||
}
|
||||
|
||||
/** Build the request row. */
|
||||
/**
|
||||
* Build the request row.
|
||||
*
|
||||
* <p>The stored variables payload comes from {@code encoded}, the same pass the fingerprint was
|
||||
* taken over. The writer used to call a JSON codec of its own, so the bytes that were hashed and
|
||||
* the bytes that were stored were produced by two different pieces of code and only agreed by
|
||||
* coincidence.
|
||||
*
|
||||
* @param plan the submission intent
|
||||
* @param encoded the canonical encoding of that plan
|
||||
* @param fingerprint the hash of {@code encoded}
|
||||
* @param scheduleAt when it should be dispatched, if later
|
||||
* @param now the clock reading
|
||||
* @return the request row
|
||||
*/
|
||||
public NotificationRequestRecord request(
|
||||
NotificationPlan plan, String fingerprint, Optional<Instant> scheduleAt, Instant now) {
|
||||
NotificationPlan plan,
|
||||
dev.caskeleton.application.notification.platform.api.EncodedNotificationPlan encoded,
|
||||
String fingerprint,
|
||||
Optional<Instant> scheduleAt,
|
||||
Instant now) {
|
||||
Objects.requireNonNull(plan, "plan");
|
||||
Objects.requireNonNull(encoded, "encoded");
|
||||
Objects.requireNonNull(fingerprint, "fingerprint");
|
||||
Objects.requireNonNull(scheduleAt, "scheduleAt");
|
||||
Objects.requireNonNull(now, "now");
|
||||
@@ -50,10 +65,13 @@ public final class CanonicalNotificationPlanWriter {
|
||||
plan.category(),
|
||||
plan.template(),
|
||||
plan.deliveryStrategy().getClass().getSimpleName(),
|
||||
variables.encode(plan.variables()),
|
||||
encoded.variablesPayload(),
|
||||
scheduleAt,
|
||||
plan.notBefore(),
|
||||
plan.expiresAt(),
|
||||
// Frozen here, at acceptance. Recomputing a collapse key at dispatch time would give a
|
||||
// retry a different key from the attempt it is retrying, which defeats collapsing.
|
||||
plan.collapse(),
|
||||
status,
|
||||
correlationOf(plan),
|
||||
plan.boundedMetadata(),
|
||||
@@ -70,10 +88,10 @@ public final class CanonicalNotificationPlanWriter {
|
||||
|
||||
Instant activationAt = request.scheduleAt().orElse(now);
|
||||
Instant dispatchAt = plan.notBefore().filter(activationAt::isBefore).orElse(activationAt);
|
||||
RecipientDeliveryState initialState =
|
||||
request.scheduleAt().isPresent()
|
||||
? RecipientDeliveryState.PENDING
|
||||
: RecipientDeliveryState.READY_TO_DISPATCH;
|
||||
// One state, not two. A scheduled delivery is an ordinary queued delivery whose due time is in
|
||||
// the future: PENDING was a second state that the claim query did not read, so every
|
||||
// schedule(...) request sat in the table until someone noticed.
|
||||
RecipientDeliveryState initialState = RecipientDeliveryState.READY_TO_DISPATCH;
|
||||
|
||||
List<RecipientDeliveryRecord> records = new ArrayList<>(plan.recipients().size());
|
||||
for (RecipientSpec recipient : plan.recipients()) {
|
||||
@@ -96,6 +114,10 @@ public final class CanonicalNotificationPlanWriter {
|
||||
false,
|
||||
false,
|
||||
Optional.of(dispatchAt),
|
||||
// The expiry travels onto the row so the claim can enforce it without reading the
|
||||
// request. A scheduled delivery whose window closed while it waited must not go out
|
||||
// late; the claim is the only place that can decide that atomically.
|
||||
request.expiresAt(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
0,
|
||||
|
||||
+4
-3
@@ -6,6 +6,7 @@ import dev.caskeleton.application.notification.platform.api.delivery.DeliveryOut
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLevel;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome;
|
||||
import dev.caskeleton.application.notification.platform.policy.RouteCandidate;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderExecutionEvidence;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
|
||||
import dev.caskeleton.application.notification.platform.template.RenderedNotificationContent;
|
||||
import java.time.Instant;
|
||||
@@ -50,9 +51,9 @@ public final class DeliveryAttemptFactory {
|
||||
profile.providerId(),
|
||||
profile.profileId(),
|
||||
Optional.empty(),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
// A row that exists before any call: nothing started, and that is a proven fact rather than
|
||||
// an unknown one.
|
||||
ProviderExecutionEvidence.notStarted(),
|
||||
SubmissionOutcome.NOT_SUBMITTED,
|
||||
DeliveryOutcome.UNKNOWN,
|
||||
AttemptConfirmation.AMBIGUOUS,
|
||||
|
||||
+14
-4
@@ -11,12 +11,23 @@ import dev.caskeleton.application.notification.platform.api.delivery.EvidenceLev
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome;
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderExecutionEvidence;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Durable state of one physical attempt. */
|
||||
/**
|
||||
* Durable state of one physical attempt.
|
||||
*
|
||||
* <p>Execution evidence is stored as {@link ProviderExecutionEvidence}, not as three booleans. The
|
||||
* three booleans were the {@code .value()} halves of four {@link
|
||||
* dev.caskeleton.application.notification.platform.provider.EvidenceFact}s, so the certainty was
|
||||
* dropped on the way to the database and {@code providerAcceptance} was not written at all. After a
|
||||
* restart, "we know the body did not reach the provider" and "we have no idea whether the body
|
||||
* reached the provider" were the same stored row — and those are the two cases that decide whether
|
||||
* a retry is safe. The ambiguity model exists to keep them apart, and persistence collapsed them.
|
||||
*/
|
||||
public record DeliveryAttemptRecord(
|
||||
DeliveryAttemptId id,
|
||||
RecipientDeliveryId recipientDeliveryId,
|
||||
@@ -26,9 +37,7 @@ public record DeliveryAttemptRecord(
|
||||
ProviderId providerId,
|
||||
ProviderProfileId providerProfileId,
|
||||
Optional<String> providerRequestId,
|
||||
boolean requestStarted,
|
||||
boolean requestBodyCommitted,
|
||||
boolean providerResponseReceived,
|
||||
ProviderExecutionEvidence executionEvidence,
|
||||
SubmissionOutcome submissionOutcome,
|
||||
DeliveryOutcome deliveryOutcome,
|
||||
AttemptConfirmation confirmation,
|
||||
@@ -51,6 +60,7 @@ public record DeliveryAttemptRecord(
|
||||
Objects.requireNonNull(providerId, "providerId");
|
||||
Objects.requireNonNull(providerProfileId, "providerProfileId");
|
||||
Objects.requireNonNull(providerRequestId, "providerRequestId");
|
||||
Objects.requireNonNull(executionEvidence, "executionEvidence");
|
||||
Objects.requireNonNull(submissionOutcome, "submissionOutcome");
|
||||
Objects.requireNonNull(deliveryOutcome, "deliveryOutcome");
|
||||
Objects.requireNonNull(confirmation, "confirmation");
|
||||
|
||||
+44
-4
@@ -21,15 +21,35 @@ public final class DispatchOutcomeRecorder {
|
||||
|
||||
private final DeliveryAttemptStorePort attempts;
|
||||
private final RecipientDeliveryStorePort recipients;
|
||||
private final ReconciliationJobStorePort reconciliationJobs;
|
||||
private final dev.caskeleton.application.notification.platform.callback.ProviderEventLedger
|
||||
ledger;
|
||||
private final dev.caskeleton.application.notification.platform.callback
|
||||
.ProviderEventProjectionService
|
||||
projection;
|
||||
private final NotificationMetricsPort metrics;
|
||||
private final java.time.Duration firstReconciliationDelay;
|
||||
|
||||
public DispatchOutcomeRecorder(
|
||||
DeliveryAttemptStorePort attempts,
|
||||
RecipientDeliveryStorePort recipients,
|
||||
NotificationMetricsPort metrics) {
|
||||
ReconciliationJobStorePort reconciliationJobs,
|
||||
dev.caskeleton.application.notification.platform.callback.ProviderEventLedger ledger,
|
||||
dev.caskeleton.application.notification.platform.callback.ProviderEventProjectionService
|
||||
projection,
|
||||
NotificationMetricsPort metrics,
|
||||
java.time.Duration firstReconciliationDelay) {
|
||||
this.attempts = Objects.requireNonNull(attempts, "attempts");
|
||||
this.recipients = Objects.requireNonNull(recipients, "recipients");
|
||||
this.reconciliationJobs = Objects.requireNonNull(reconciliationJobs, "reconciliationJobs");
|
||||
this.ledger = Objects.requireNonNull(ledger, "ledger");
|
||||
this.projection = Objects.requireNonNull(projection, "projection");
|
||||
this.metrics = Objects.requireNonNull(metrics, "metrics");
|
||||
this.firstReconciliationDelay =
|
||||
Objects.requireNonNull(firstReconciliationDelay, "firstReconciliationDelay");
|
||||
if (firstReconciliationDelay.isNegative()) {
|
||||
throw new IllegalArgumentException("firstReconciliationDelay must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
/** Record the provider result on the attempt and roll it up into the recipient job. */
|
||||
@@ -53,9 +73,10 @@ public final class DispatchOutcomeRecorder {
|
||||
attempt.providerId(),
|
||||
attempt.providerProfileId(),
|
||||
result.providerRequestId(),
|
||||
result.executionEvidence().requestStarted().value(),
|
||||
result.executionEvidence().requestBodyCommitted().value(),
|
||||
result.executionEvidence().responseReceived().value(),
|
||||
// The whole evidence, not the .value() of three of its four facts. Dropping the
|
||||
// certainty made "proven not sent" and "unknown whether sent" the same stored row, and
|
||||
// those two decide whether a retry can safely happen.
|
||||
result.executionEvidence(),
|
||||
result.submissionOutcome(),
|
||||
result.deliveryOutcome(),
|
||||
result.confirmation(),
|
||||
@@ -72,6 +93,18 @@ public final class DispatchOutcomeRecorder {
|
||||
|
||||
DeliveryAttemptRecord stored = attempts.recordOutcome(completed);
|
||||
|
||||
// The provider's request id is now on the attempt, so any callback that arrived before this
|
||||
// moment can finally be attached to it. A provider answering faster than the caller commits is
|
||||
// ordinary, and until this call nothing ever went back for those events: they stayed unmatched
|
||||
// permanently and their delivery was never confirmed.
|
||||
result
|
||||
.providerRequestId()
|
||||
.ifPresent(
|
||||
requestId ->
|
||||
ledger
|
||||
.bindUnmatched(attempt.providerProfileId(), requestId, stored.id())
|
||||
.forEach(projection::project));
|
||||
|
||||
boolean ambiguous = result.confirmation() == AttemptConfirmation.AMBIGUOUS;
|
||||
RecipientDeliveryRecord updated =
|
||||
new RecipientDeliveryRecord(
|
||||
@@ -92,6 +125,7 @@ public final class DispatchOutcomeRecorder {
|
||||
recipient.ambiguousAttemptExists() || ambiguous,
|
||||
recipient.duplicateRisk() || ambiguous,
|
||||
recipient.nextDispatchAt(),
|
||||
recipient.expiresAt(),
|
||||
recipient.leaseOwner(),
|
||||
recipient.leaseUntil(),
|
||||
recipient.attemptCount() + 1,
|
||||
@@ -112,6 +146,12 @@ public final class DispatchOutcomeRecorder {
|
||||
Map.of("channel", attempt.channel().name(), "provider", attempt.providerId().value()));
|
||||
}
|
||||
if (ambiguous) {
|
||||
// The delivery is now RECONCILIATION_REQUIRED, and until this line nothing ever asked what
|
||||
// happened to it: reconciliation ran only when a lease expired and recovery walked past the
|
||||
// attempt, so a worker that recorded AMBIGUOUS and then exited cleanly left the delivery in
|
||||
// that state permanently.
|
||||
reconciliationJobs.schedule(
|
||||
stored.id(), attempt.providerProfileId(), completedAt.plus(firstReconciliationDelay));
|
||||
metrics.increment(
|
||||
NotificationMetricName.AMBIGUOUS,
|
||||
Map.of("channel", attempt.channel().name(), "provider", attempt.providerId().value()));
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.capability.Idempotency;
|
||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationSnapshot;
|
||||
import dev.caskeleton.application.notification.platform.port.in.GetNotificationQuery;
|
||||
import dev.caskeleton.application.notification.platform.port.in.GetNotificationUseCase;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.application.transaction.TransactionMode;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Reading a notification's current state.
|
||||
*
|
||||
* <p>A {@code QueryUseCase}, so the fitness gate holds it to {@code READ_ONLY} and {@code
|
||||
* READ_REPOSITORY}. While this shared an interface with three writes, no single declaration could
|
||||
* be true of all four, so none was made.
|
||||
*/
|
||||
@UseCaseCapability(
|
||||
transactionMode = TransactionMode.READ_ONLY,
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
||||
@RequiresPermission("notification:read")
|
||||
public final class GetNotificationApplicationUseCase implements GetNotificationUseCase {
|
||||
|
||||
private final NotificationSubmissionService service;
|
||||
private final TransactionPort transactions;
|
||||
|
||||
public GetNotificationApplicationUseCase(
|
||||
NotificationSubmissionService service, TransactionPort transactions) {
|
||||
this.service = Objects.requireNonNull(service, "service");
|
||||
// Injected, and called directly below. A use case that declares it owns a repository-backed
|
||||
// transaction and then delegates the boundary to a collaborator is making a claim the
|
||||
// architecture gate cannot verify and a reader cannot trust.
|
||||
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationSnapshot handle(GetNotificationQuery query) {
|
||||
Objects.requireNonNull(query, "query");
|
||||
return transactions.inRead(() -> service.readInTransaction(query.notificationId()));
|
||||
}
|
||||
}
|
||||
+86
-10
@@ -24,6 +24,7 @@ import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -100,7 +101,7 @@ public final class NotificationDispatchService {
|
||||
|
||||
Optional<Work> loaded = transactions.inRead(() -> load(lease));
|
||||
if (loaded.isEmpty()) {
|
||||
leases.release(lease.recipientDeliveryId(), lease.owner());
|
||||
leases.release(lease);
|
||||
return;
|
||||
}
|
||||
Work work = loaded.get();
|
||||
@@ -108,7 +109,7 @@ public final class NotificationDispatchService {
|
||||
RoutingDecision decision = routing.next(routingContext(work, now));
|
||||
if (decision.selected().isEmpty()) {
|
||||
applyRoutingStop(work, decision);
|
||||
leases.release(lease.recipientDeliveryId(), lease.owner());
|
||||
leases.release(lease);
|
||||
return;
|
||||
}
|
||||
RouteCandidate route = decision.selected().get();
|
||||
@@ -124,8 +125,8 @@ public final class NotificationDispatchService {
|
||||
if (guard instanceof DispatchGuardOutcome.Blocked blocked) {
|
||||
transactions.inWrite(
|
||||
() -> recipients.transition(work.recipient().id(), blocked.state(), Optional.empty()));
|
||||
requests.refreshStatus(work.request().id());
|
||||
leases.release(lease.recipientDeliveryId(), lease.owner());
|
||||
refreshStatus(work);
|
||||
leases.release(lease);
|
||||
return;
|
||||
}
|
||||
ContactPointRecord contactPoint = ((DispatchGuardOutcome.Proceed) guard).contactPoint();
|
||||
@@ -145,6 +146,13 @@ public final class NotificationDispatchService {
|
||||
attempts.nextAttemptNo(work.recipient().id()),
|
||||
clock.instant())));
|
||||
|
||||
if (!leases.stillHeld(lease)) {
|
||||
// Checked immediately before the side effect, which is the last moment it can still be
|
||||
// prevented. Everything above is database work another holder would simply redo; a provider
|
||||
// submission is not — once it leaves, the recipient has the notification twice.
|
||||
return;
|
||||
}
|
||||
|
||||
ProviderSubmissionResult result =
|
||||
submitOutsideTransaction(attempt, profile, contactPoint, content, work);
|
||||
|
||||
@@ -154,7 +162,25 @@ public final class NotificationDispatchService {
|
||||
|
||||
RetryDecision next = retryPolicy.decide(retryContext(work, recorded, result, profile));
|
||||
applyNextAction(work, recorded, next, clock.instant());
|
||||
leases.release(lease.recipientDeliveryId(), lease.owner());
|
||||
leases.release(lease);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes and stores the request status.
|
||||
*
|
||||
* <p>The roll-up decision is applied here, in the use case, and the store is told the answer. It
|
||||
* used to be made inside the JPA adapter, which imported this package's submission service to do
|
||||
* it — so a rule about what "partially completed" means lived behind a persistence class.
|
||||
*/
|
||||
private void refreshStatus(Work work) {
|
||||
List<RecipientDeliveryState> states =
|
||||
requests.recipientsOf(work.request().tenantId(), work.request().id()).stream()
|
||||
.map(RecipientDeliveryRecord::state)
|
||||
.toList();
|
||||
requests.updateStatus(
|
||||
work.request().tenantId(),
|
||||
work.request().id(),
|
||||
NotificationRequestStatusPolicy.rollUp(states));
|
||||
}
|
||||
|
||||
private ProviderSubmissionResult submitOutsideTransaction(
|
||||
@@ -172,14 +198,39 @@ public final class NotificationDispatchService {
|
||||
content,
|
||||
work.request().expiresAt(),
|
||||
Optional.of(attempt.id().value().toString()),
|
||||
Optional.empty(),
|
||||
// The frozen collapse, not Optional.empty(). The APNs and FCM mappers can send one and
|
||||
// the capability model has a flag for it; the value simply never arrived.
|
||||
requireCollapseSupported(profile, work),
|
||||
java.util.Map.of(),
|
||||
TraceContext.NONE);
|
||||
try {
|
||||
return gateway.submit(submission);
|
||||
} catch (
|
||||
dev.caskeleton.application.notification.platform.provider.ProviderCallNotStartedException
|
||||
notStarted) {
|
||||
// The adapter knows the transport never started: a payload that failed mapping, a contact
|
||||
// point that could not be revealed, a profile with no credential.
|
||||
return ProviderSubmissionResult.notSubmitted(notStarted.toFailure(), Duration.ZERO);
|
||||
} catch (
|
||||
dev.caskeleton.application.notification.platform.api.error.NotificationException
|
||||
platformFailure) {
|
||||
// The platform's own failures already say whether the submission could have been transmitted:
|
||||
// NotificationFailureDescriptor.preDispatch sets ambiguous=false, and every limiter, runtime
|
||||
// state and configuration rejection uses it. That flag was computed, recorded, and then
|
||||
// discarded by a catch that treated all of them as "may already have been sent".
|
||||
var descriptor = platformFailure.descriptor();
|
||||
if (!descriptor.ambiguous()) {
|
||||
return ProviderSubmissionResult.notSubmitted(
|
||||
ProviderFailure.of(descriptor.code(), descriptor.category(), descriptor.retryable()),
|
||||
Duration.ZERO);
|
||||
}
|
||||
return ProviderSubmissionResult.ambiguous(
|
||||
ProviderFailure.of(descriptor.code(), descriptor.category(), descriptor.retryable()),
|
||||
ProviderExecutionEvidence.responseLost(),
|
||||
Duration.ZERO);
|
||||
} catch (RuntimeException transportFailure) {
|
||||
// A gateway that throws instead of classifying leaves us unable to prove anything about the
|
||||
// submission, so the only honest record is an ambiguous one.
|
||||
// Anything the adapter did not classify. The bytes may have reached the provider, so the only
|
||||
// honest record is an ambiguous one — this branch is the residue, not the default.
|
||||
return ProviderSubmissionResult.ambiguous(
|
||||
ProviderFailure.of(
|
||||
NotificationFailureCode.PROVIDER_RESPONSE_LOST,
|
||||
@@ -190,6 +241,30 @@ public final class NotificationDispatchService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The request's collapse specification, refused when the provider cannot honour it.
|
||||
*
|
||||
* <p>Silently dropping it would send a notification the caller believes will replace an earlier
|
||||
* one and which will not — the user sees two. A typed pre-dispatch failure is the honest answer:
|
||||
* nothing is transmitted, and the operator learns that this profile cannot do what was asked.
|
||||
*/
|
||||
private static Optional<dev.caskeleton.application.notification.platform.api.CollapseSpec>
|
||||
requireCollapseSupported(ProviderProfileSnapshot profile, Work work) {
|
||||
var collapse = work.request().collapse();
|
||||
if (collapse.isEmpty()) {
|
||||
return collapse;
|
||||
}
|
||||
if (!profile.capabilities().collapse()) {
|
||||
throw new dev.caskeleton.application.notification.platform.provider
|
||||
.ProviderCallNotStartedException(
|
||||
NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID,
|
||||
FailureCategory.INVALID_PAYLOAD,
|
||||
false,
|
||||
"the request asks for collapsing and this provider profile does not support it");
|
||||
}
|
||||
return collapse;
|
||||
}
|
||||
|
||||
private RenderedNotificationContent render(Work work, RouteCandidate route) {
|
||||
return renderers
|
||||
.rendererFor(route.channel())
|
||||
@@ -267,7 +342,7 @@ public final class NotificationDispatchService {
|
||||
transactions.inWrite(
|
||||
() -> {
|
||||
recipients.transition(work.recipient().id(), state, Optional.empty());
|
||||
requests.refreshStatus(work.request().id());
|
||||
refreshStatus(work);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -296,7 +371,7 @@ public final class NotificationDispatchService {
|
||||
: RecipientDeliveryState.FAILED,
|
||||
Optional.empty());
|
||||
}
|
||||
requests.refreshStatus(work.request().id());
|
||||
refreshStatus(work);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -318,6 +393,7 @@ public final class NotificationDispatchService {
|
||||
recipient.ambiguousAttemptExists(),
|
||||
recipient.duplicateRisk(),
|
||||
Optional.of(now),
|
||||
recipient.expiresAt(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
recipient.attemptCount(),
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* What the database did with an insert, said without throwing.
|
||||
*
|
||||
* <p>The loser of an idempotency race used to find out by catching a unique-violation exception and
|
||||
* then reading the winner — in the same transaction. PostgreSQL leaves a transaction aborted after
|
||||
* a statement-level constraint violation and refuses every subsequent statement until rollback, so
|
||||
* that read failed with SQLSTATE 25P02 or surfaced later as {@code UnexpectedRollbackException}.
|
||||
* The convergence the whole idempotency contract promises — same key, same fingerprint, same
|
||||
* receipt — could not happen on the path where it mattered.
|
||||
*
|
||||
* <p>An outcome instead of an exception, because the database can answer this without poisoning
|
||||
* anything: {@code ON CONFLICT DO NOTHING RETURNING} returns a row when the caller won and no rows
|
||||
* when it lost, and losing is an ordinary result rather than an error.
|
||||
*
|
||||
* @param stored the request as the database now holds it
|
||||
* @param inserted whether this caller is the one that created it
|
||||
*/
|
||||
public record NotificationRequestInsertOutcome(NotificationRequestRecord stored, boolean inserted) {
|
||||
|
||||
public NotificationRequestInsertOutcome {
|
||||
Objects.requireNonNull(stored, "stored");
|
||||
}
|
||||
|
||||
/** This caller created the row. */
|
||||
public static NotificationRequestInsertOutcome won(NotificationRequestRecord stored) {
|
||||
return new NotificationRequestInsertOutcome(stored, true);
|
||||
}
|
||||
|
||||
/** Another caller created it first; this is what they stored. */
|
||||
public static NotificationRequestInsertOutcome lost(NotificationRequestRecord winner) {
|
||||
return new NotificationRequestInsertOutcome(winner, false);
|
||||
}
|
||||
}
|
||||
+45
-1
@@ -23,6 +23,7 @@ public record NotificationRequestRecord(
|
||||
Optional<Instant> scheduleAt,
|
||||
Optional<Instant> notBefore,
|
||||
Optional<Instant> expiresAt,
|
||||
Optional<dev.caskeleton.application.notification.platform.api.CollapseSpec> collapse,
|
||||
RequestStatus status,
|
||||
CorrelationId correlationId,
|
||||
Map<String, String> metadata,
|
||||
@@ -31,6 +32,7 @@ public record NotificationRequestRecord(
|
||||
|
||||
public NotificationRequestRecord {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(collapse, "collapse");
|
||||
Objects.requireNonNull(tenantId, "tenantId");
|
||||
Objects.requireNonNull(idempotencyKey, "idempotencyKey");
|
||||
Objects.requireNonNull(requestFingerprint, "requestFingerprint");
|
||||
@@ -50,7 +52,49 @@ public record NotificationRequestRecord(
|
||||
|
||||
/** Receipt for this stored request. */
|
||||
public dev.caskeleton.application.notification.platform.api.NotificationReceipt receipt() {
|
||||
return new dev.caskeleton.application.notification.platform.api.NotificationReceipt(
|
||||
return dev.caskeleton.application.notification.platform.api.NotificationReceipt.accepted(
|
||||
id, status, createdAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* The receipt for a submission an earlier notification already covers.
|
||||
*
|
||||
* @return a receipt naming the earlier notification and saying so
|
||||
*/
|
||||
public dev.caskeleton.application.notification.platform.api.NotificationReceipt
|
||||
convergedReceipt() {
|
||||
return new dev.caskeleton.application.notification.platform.api.NotificationReceipt(
|
||||
id,
|
||||
status,
|
||||
createdAt,
|
||||
dev.caskeleton.application.notification.platform.api.NotificationAcceptance
|
||||
.CONVERGED_ON_EXISTING);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same request with a different status.
|
||||
*
|
||||
* @param newStatus the status the use case decided on
|
||||
*/
|
||||
public NotificationRequestRecord withStatus(RequestStatus newStatus) {
|
||||
Objects.requireNonNull(newStatus, "status");
|
||||
return new NotificationRequestRecord(
|
||||
id,
|
||||
tenantId,
|
||||
idempotencyKey,
|
||||
requestFingerprint,
|
||||
category,
|
||||
template,
|
||||
strategyType,
|
||||
variablesPayload,
|
||||
scheduleAt,
|
||||
notBefore,
|
||||
expiresAt,
|
||||
collapse,
|
||||
newStatus,
|
||||
correlationId,
|
||||
metadata,
|
||||
createdAt,
|
||||
updatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.RequestStatus;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* What a request's status is, given the state of its recipient jobs.
|
||||
*
|
||||
* <p>A policy in application-core, because that is what it is: "one recipient completed and one
|
||||
* failed means partially completed" is a product decision, not a storage detail. The persistence
|
||||
* adapter used to import {@code NotificationSubmissionService} and call this decision itself, which
|
||||
* put a business rule table behind a JPA store and meant changing the rule required editing both
|
||||
* layers in step.
|
||||
*/
|
||||
public final class NotificationRequestStatusPolicy {
|
||||
|
||||
private NotificationRequestStatusPolicy() {}
|
||||
|
||||
/** Status the request should hold given its recipient jobs. */
|
||||
public static RequestStatus rollUp(List<RecipientDeliveryState> states) {
|
||||
Objects.requireNonNull(states, "states");
|
||||
if (states.isEmpty()) {
|
||||
return RequestStatus.CREATED;
|
||||
}
|
||||
boolean allTerminal = states.stream().allMatch(NotificationRequestStatusPolicy::isTerminal);
|
||||
boolean anyCompleted = states.contains(RecipientDeliveryState.COMPLETED);
|
||||
if (!allTerminal) {
|
||||
return RequestStatus.PROCESSING;
|
||||
}
|
||||
if (states.stream().allMatch(state -> state == RecipientDeliveryState.CANCELED)) {
|
||||
return RequestStatus.CANCELED;
|
||||
}
|
||||
if (states.stream().allMatch(state -> state == RecipientDeliveryState.EXPIRED)) {
|
||||
return RequestStatus.EXPIRED;
|
||||
}
|
||||
if (anyCompleted
|
||||
&& states.stream().anyMatch(state -> state != RecipientDeliveryState.COMPLETED)) {
|
||||
return RequestStatus.PARTIALLY_COMPLETED;
|
||||
}
|
||||
return anyCompleted ? RequestStatus.COMPLETED : RequestStatus.FAILED;
|
||||
}
|
||||
|
||||
/** Whether a recipient job has reached a state it will not leave. */
|
||||
public static boolean isTerminal(RecipientDeliveryState state) {
|
||||
return switch (state) {
|
||||
case COMPLETED, FAILED, EXPIRED, CANCELED, SUPPRESSED -> true;
|
||||
case PENDING, READY_TO_DISPATCH, DISPATCHING, RETRY_WAITING, RECONCILIATION_REQUIRED -> false;
|
||||
};
|
||||
}
|
||||
}
|
||||
+32
-7
@@ -1,15 +1,35 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationId;
|
||||
import dev.caskeleton.application.notification.platform.api.RequestStatus;
|
||||
import dev.caskeleton.application.notification.platform.api.TenantId;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Tenant-scoped durable store for logical requests and recipient jobs. */
|
||||
/**
|
||||
* Tenant-scoped durable store for logical requests and recipient jobs.
|
||||
*
|
||||
* <p>Every operation takes the tenant. Two of them used to take only a {@code NotificationId}, and
|
||||
* were safe purely because their callers happened to check the tenant first — a property no
|
||||
* signature expressed and no new caller would know to preserve. A store that accepts another
|
||||
* tenant's identifier and answers is a cross-tenant read waiting for the first caller who forgets.
|
||||
*
|
||||
* <p>The store also no longer decides what a status should be. {@link
|
||||
* NotificationRequestStatusPolicy} owns that, the use case applies it, and this records the result.
|
||||
*/
|
||||
public interface NotificationRequestStorePort {
|
||||
|
||||
/** Insert a request with its recipient jobs in one unit of work. */
|
||||
NotificationRequestRecord insert(
|
||||
/**
|
||||
* Insert a request with its recipient jobs in one unit of work, or report that someone else did.
|
||||
*
|
||||
* <p>Returns rather than throws when the idempotency key is already claimed. A unique-violation
|
||||
* exception leaves a PostgreSQL transaction aborted, so the caller that catches it cannot then
|
||||
* read the winner — which is exactly what the idempotency contract asks it to do.
|
||||
*
|
||||
* <p>Recipient jobs are written only when this caller won. Writing them on the losing path would
|
||||
* attach a second set of jobs to the winner's request.
|
||||
*/
|
||||
NotificationRequestInsertOutcome insert(
|
||||
NotificationRequestRecord request, List<RecipientDeliveryRecord> recipients);
|
||||
|
||||
/** Look up by the idempotency scope. */
|
||||
@@ -18,9 +38,14 @@ public interface NotificationRequestStorePort {
|
||||
/** Look up by identity, inside a tenant. */
|
||||
Optional<NotificationRequestRecord> findById(TenantId tenantId, NotificationId id);
|
||||
|
||||
/** Recipient jobs of a request. */
|
||||
List<RecipientDeliveryRecord> recipientsOf(NotificationId notificationId);
|
||||
/** Recipient jobs of a request, inside a tenant. */
|
||||
List<RecipientDeliveryRecord> recipientsOf(TenantId tenantId, NotificationId notificationId);
|
||||
|
||||
/** Recompute and store the request status from its recipient jobs. */
|
||||
NotificationRequestRecord refreshStatus(NotificationId notificationId);
|
||||
/**
|
||||
* Store a status the caller has already decided.
|
||||
*
|
||||
* @throws IllegalStateException when the request does not exist in this tenant
|
||||
*/
|
||||
NotificationRequestRecord updateStatus(
|
||||
TenantId tenantId, NotificationId notificationId, RequestStatus status);
|
||||
}
|
||||
|
||||
+242
-130
@@ -7,7 +7,6 @@ import dev.caskeleton.application.notification.platform.api.NotificationOrchestr
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationPlan;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationReceipt;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationSnapshot;
|
||||
import dev.caskeleton.application.notification.platform.api.RequestStatus;
|
||||
import dev.caskeleton.application.notification.platform.api.TenantId;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome;
|
||||
@@ -43,9 +42,13 @@ public final class NotificationSubmissionService implements NotificationOrchestr
|
||||
private final DeliveryAttemptStorePort attempts;
|
||||
private final TemplateRegistry templates;
|
||||
private final RequestFingerprint fingerprints;
|
||||
private final CanonicalNotificationPlanEncoder planEncoder =
|
||||
new CanonicalNotificationPlanEncoder();
|
||||
private final CanonicalNotificationPlanWriter writer;
|
||||
private final TransactionPort transactions;
|
||||
private final TenantContextPort tenants;
|
||||
private final dev.caskeleton.application.notification.platform.policy.DeduplicationService
|
||||
deduplication;
|
||||
private final NotificationMetricsPort metrics;
|
||||
private final Clock clock;
|
||||
|
||||
@@ -58,6 +61,7 @@ public final class NotificationSubmissionService implements NotificationOrchestr
|
||||
CanonicalNotificationPlanWriter writer,
|
||||
TransactionPort transactions,
|
||||
TenantContextPort tenants,
|
||||
dev.caskeleton.application.notification.platform.policy.DeduplicationService deduplication,
|
||||
NotificationMetricsPort metrics,
|
||||
Clock clock) {
|
||||
this.requests = Objects.requireNonNull(requests, "requests");
|
||||
@@ -68,6 +72,7 @@ public final class NotificationSubmissionService implements NotificationOrchestr
|
||||
this.writer = Objects.requireNonNull(writer, "writer");
|
||||
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||
this.tenants = Objects.requireNonNull(tenants, "tenants");
|
||||
this.deduplication = Objects.requireNonNull(deduplication, "deduplication");
|
||||
this.metrics = Objects.requireNonNull(metrics, "metrics");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
@@ -83,7 +88,23 @@ public final class NotificationSubmissionService implements NotificationOrchestr
|
||||
return accept(plan, Optional.of(scheduleAt));
|
||||
}
|
||||
|
||||
private NotificationReceipt accept(NotificationPlan plan, Optional<Instant> scheduleAt) {
|
||||
/**
|
||||
* Everything an acceptance does before a transaction is needed.
|
||||
*
|
||||
* <p>Split out so the inbound use case can own the transaction boundary itself. The boundary used
|
||||
* to be opened here, which meant a use case declaring {@code WRITE + WRITE_REPOSITORY} did not
|
||||
* actually call {@code TransactionPort} — a capability claim the architecture gate correctly
|
||||
* refused, because a declaration nothing performs is the defect this whole review is about.
|
||||
*
|
||||
* <p>Template pinning and encoding stay outside the transaction: a version that does not exist
|
||||
* can never be rendered later, so the check belongs before the write, not inside it holding a
|
||||
* connection.
|
||||
*
|
||||
* @param plan the submission intent
|
||||
* @param scheduleAt when it may first be dispatched
|
||||
* @return everything the committed part needs
|
||||
*/
|
||||
PreparedSubmission prepare(NotificationPlan plan, Optional<Instant> scheduleAt) {
|
||||
Objects.requireNonNull(plan, "plan");
|
||||
Instant now = clock.instant();
|
||||
scheduleAt.ifPresent(
|
||||
@@ -94,43 +115,137 @@ public final class NotificationSubmissionService implements NotificationOrchestr
|
||||
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
|
||||
}
|
||||
});
|
||||
|
||||
// Pinning the template before the write is what makes the fingerprint meaningful: a version
|
||||
// that
|
||||
// does not exist can never be rendered later, so accepting it would only defer the failure.
|
||||
templates.get(plan.template());
|
||||
// One encode per submission. The fingerprint and the stored payload both come out of it, so
|
||||
// there is no second rendering of the request that could disagree with the first.
|
||||
var encoded = planEncoder.encode(plan);
|
||||
return new PreparedSubmission(encoded, fingerprints.of(encoded), scheduleAt, now);
|
||||
}
|
||||
|
||||
String fingerprint = fingerprints.of(plan);
|
||||
NotificationReceipt receipt =
|
||||
transactions.inWrite(
|
||||
() -> {
|
||||
Optional<NotificationRequestRecord> existing =
|
||||
requests.findByIdempotency(plan.tenantId(), plan.idempotencyKey().value());
|
||||
if (existing.isPresent()) {
|
||||
return converge(existing.get(), fingerprint);
|
||||
}
|
||||
try {
|
||||
NotificationRequestRecord request =
|
||||
writer.request(plan, fingerprint, scheduleAt, now);
|
||||
List<RecipientDeliveryRecord> jobs = writer.recipients(plan, request, now);
|
||||
return requests.insert(request, jobs).receipt();
|
||||
} catch (DuplicateIdempotencyKeyException concurrent) {
|
||||
NotificationRequestRecord winner =
|
||||
requests
|
||||
.findByIdempotency(plan.tenantId(), plan.idempotencyKey().value())
|
||||
.orElseThrow(() -> concurrent);
|
||||
return converge(winner, fingerprint);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* The part that must run inside the caller's write transaction.
|
||||
*
|
||||
* @param plan the submission intent
|
||||
* @param prepared what {@link #prepare} produced
|
||||
* @return the receipt
|
||||
*/
|
||||
NotificationReceipt commit(NotificationPlan plan, PreparedSubmission prepared) {
|
||||
Optional<NotificationRequestRecord> existing =
|
||||
requests.findByIdempotency(plan.tenantId(), plan.idempotencyKey().value());
|
||||
if (existing.isPresent()) {
|
||||
return converge(existing.get(), prepared.fingerprint());
|
||||
}
|
||||
NotificationRequestRecord request =
|
||||
writer.request(
|
||||
plan,
|
||||
prepared.encoded(),
|
||||
prepared.fingerprint(),
|
||||
prepared.scheduleAt(),
|
||||
prepared.now());
|
||||
// The deduplication claim lives inside this transaction, next to the insert it guards.
|
||||
Optional<NotificationReceipt> deduplicated = applyDeduplication(plan, request, prepared.now());
|
||||
if (deduplicated.isPresent()) {
|
||||
return deduplicated.get();
|
||||
}
|
||||
List<RecipientDeliveryRecord> jobs = writer.recipients(plan, request, prepared.now());
|
||||
// The store reports the race instead of raising it. Catching a unique violation left the
|
||||
// PostgreSQL transaction aborted, so the read that follows could not run.
|
||||
NotificationRequestInsertOutcome outcome = requests.insert(request, jobs);
|
||||
return outcome.inserted()
|
||||
? outcome.stored().receipt()
|
||||
: converge(outcome.stored(), prepared.fingerprint());
|
||||
}
|
||||
|
||||
/**
|
||||
* Count one accepted submission.
|
||||
*
|
||||
* @param plan the accepted plan
|
||||
*/
|
||||
void recordAccepted(NotificationPlan plan) {
|
||||
metrics.increment(
|
||||
NotificationMetricName.REQUESTED,
|
||||
Map.of(
|
||||
"notificationCategory", plan.category(),
|
||||
"strategy", plan.deliveryStrategy().getClass().getSimpleName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* What an acceptance computed before it needed a transaction.
|
||||
*
|
||||
* @param encoded the canonical encoding
|
||||
* @param fingerprint its hash
|
||||
* @param scheduleAt when it may first be dispatched
|
||||
* @param now the clock reading the whole acceptance uses
|
||||
*/
|
||||
record PreparedSubmission(
|
||||
dev.caskeleton.application.notification.platform.api.EncodedNotificationPlan encoded,
|
||||
String fingerprint,
|
||||
Optional<Instant> scheduleAt,
|
||||
Instant now) {}
|
||||
|
||||
private NotificationReceipt accept(NotificationPlan plan, Optional<Instant> scheduleAt) {
|
||||
PreparedSubmission prepared = prepare(plan, scheduleAt);
|
||||
NotificationReceipt receipt = transactions.inWrite(() -> commit(plan, prepared));
|
||||
recordAccepted(plan);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims the deduplication window, when the plan asks for one.
|
||||
*
|
||||
* <p>Multi-recipient plans are refused rather than guessed at. The window is keyed on one
|
||||
* recipient identity, and a plan with several recipients has no single answer to "which recipient
|
||||
* is this duplicate of" — accepting it would deduplicate against an arbitrary one of them.
|
||||
*
|
||||
* @return the receipt to return, when deduplication decided the outcome
|
||||
*/
|
||||
private Optional<NotificationReceipt> applyDeduplication(
|
||||
NotificationPlan plan, NotificationRequestRecord request, Instant now) {
|
||||
Optional<dev.caskeleton.application.notification.platform.api.DeduplicationSpec> spec =
|
||||
plan.deduplication();
|
||||
if (spec.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (plan.recipients().size() != 1) {
|
||||
throw new NotificationValidationException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
|
||||
}
|
||||
var identity =
|
||||
new dev.caskeleton.application.notification.platform.policy.RecipientIdentity(
|
||||
plan.tenantId(), plan.recipients().get(0).recipientRef(), plan.category());
|
||||
var result = deduplication.evaluate(identity, spec.get(), request.id());
|
||||
if (result.existingNotificationId().isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
// Two different outcomes, and the caller has to be able to tell them apart: DROP means nothing
|
||||
// will ever be sent for this submission, RETURN_EXISTING means the id in the receipt is an
|
||||
// earlier notification that covers it.
|
||||
return Optional.of(
|
||||
switch (spec.get().action()) {
|
||||
case DROP ->
|
||||
new NotificationReceipt(
|
||||
request.id(),
|
||||
dev.caskeleton.application.notification.platform.api.RequestStatus.CANCELED,
|
||||
now,
|
||||
dev.caskeleton.application.notification.platform.api.NotificationAcceptance
|
||||
.DROPPED_AS_DUPLICATE);
|
||||
case RETURN_EXISTING ->
|
||||
requests
|
||||
.findById(plan.tenantId(), result.existingNotificationId().get())
|
||||
.map(NotificationRequestRecord::convergedReceipt)
|
||||
.orElseGet(
|
||||
() ->
|
||||
new NotificationReceipt(
|
||||
result.existingNotificationId().get(),
|
||||
dev.caskeleton.application.notification.platform.api.RequestStatus
|
||||
.CREATED,
|
||||
now,
|
||||
dev.caskeleton.application.notification.platform.api
|
||||
.NotificationAcceptance.CONVERGED_ON_EXISTING));
|
||||
});
|
||||
}
|
||||
|
||||
private NotificationReceipt converge(NotificationRequestRecord existing, String fingerprint) {
|
||||
if (!existing.requestFingerprint().equals(fingerprint)) {
|
||||
throw new IdempotencyConflictException(
|
||||
@@ -144,90 +259,118 @@ public final class NotificationSubmissionService implements NotificationOrchestr
|
||||
public CancelResult cancel(NotificationId notificationId, CancelCommand command) {
|
||||
Objects.requireNonNull(notificationId, "notificationId");
|
||||
Objects.requireNonNull(command, "command");
|
||||
return transactions.inWrite(() -> cancelInTransaction(notificationId, command));
|
||||
}
|
||||
|
||||
/**
|
||||
* The cancellation body, which the caller's write transaction wraps.
|
||||
*
|
||||
* @param notificationId which notification
|
||||
* @param command the cancellation request
|
||||
* @return what was actually stopped
|
||||
*/
|
||||
CancelResult cancelInTransaction(NotificationId notificationId, CancelCommand command) {
|
||||
TenantId tenant = tenants.currentTenant();
|
||||
{
|
||||
{
|
||||
if (requests.findById(tenant, notificationId).isEmpty()) {
|
||||
throw new NotificationValidationException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
|
||||
}
|
||||
|
||||
return transactions.inWrite(
|
||||
() -> {
|
||||
if (requests.findById(tenant, notificationId).isEmpty()) {
|
||||
throw new NotificationValidationException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
|
||||
int canceled = 0;
|
||||
int terminal = 0;
|
||||
boolean uncertain = false;
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
for (RecipientDeliveryRecord job : requests.recipientsOf(tenant, notificationId)) {
|
||||
if (NotificationRequestStatusPolicy.isTerminal(job.state())) {
|
||||
terminal++;
|
||||
continue;
|
||||
}
|
||||
|
||||
int canceled = 0;
|
||||
int terminal = 0;
|
||||
boolean uncertain = false;
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
for (RecipientDeliveryRecord job : requests.recipientsOf(notificationId)) {
|
||||
if (isTerminal(job.state())) {
|
||||
if (job.ambiguousAttemptExists()
|
||||
|| job.submissionOutcome() == SubmissionOutcome.CONFIRMED_ACCEPTED) {
|
||||
// The logical follow-up stops, but a provider that already accepted the submission
|
||||
// will not un-send it. Saying otherwise here would be the lie the whole evidence
|
||||
// model
|
||||
// exists to avoid.
|
||||
uncertain = true;
|
||||
reasons.add("EXTERNAL_SIDE_EFFECT_UNCERTAIN");
|
||||
if (!command.cancelAmbiguousFollowUps()) {
|
||||
terminal++;
|
||||
continue;
|
||||
}
|
||||
if (job.ambiguousAttemptExists()
|
||||
|| job.submissionOutcome() == SubmissionOutcome.CONFIRMED_ACCEPTED) {
|
||||
// The logical follow-up stops, but a provider that already accepted the submission
|
||||
// will not un-send it. Saying otherwise here would be the lie the whole evidence
|
||||
// model
|
||||
// exists to avoid.
|
||||
uncertain = true;
|
||||
reasons.add("EXTERNAL_SIDE_EFFECT_UNCERTAIN");
|
||||
if (!command.cancelAmbiguousFollowUps()) {
|
||||
terminal++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
recipients.transition(job.id(), RecipientDeliveryState.CANCELED, Optional.empty());
|
||||
canceled++;
|
||||
}
|
||||
requests.refreshStatus(notificationId);
|
||||
reasons.add(command.reasonCode());
|
||||
return new CancelResult(
|
||||
notificationId, canceled, terminal, uncertain, List.copyOf(reasons));
|
||||
});
|
||||
recipients.transition(job.id(), RecipientDeliveryState.CANCELED, Optional.empty());
|
||||
canceled++;
|
||||
}
|
||||
requests.updateStatus(
|
||||
tenant,
|
||||
notificationId,
|
||||
NotificationRequestStatusPolicy.rollUp(
|
||||
requests.recipientsOf(tenant, notificationId).stream()
|
||||
.map(RecipientDeliveryRecord::state)
|
||||
.toList()));
|
||||
reasons.add(command.reasonCode());
|
||||
return new CancelResult(
|
||||
notificationId, canceled, terminal, uncertain, List.copyOf(reasons));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationSnapshot get(NotificationId notificationId) {
|
||||
Objects.requireNonNull(notificationId, "notificationId");
|
||||
return transactions.inRead(() -> readInTransaction(notificationId));
|
||||
}
|
||||
|
||||
/**
|
||||
* The read body, which the caller's read transaction wraps.
|
||||
*
|
||||
* @param notificationId which notification
|
||||
* @return the snapshot
|
||||
*/
|
||||
NotificationSnapshot readInTransaction(NotificationId notificationId) {
|
||||
TenantId tenant = tenants.currentTenant();
|
||||
return transactions.inRead(
|
||||
() -> {
|
||||
NotificationRequestRecord request =
|
||||
requests
|
||||
.findById(tenant, notificationId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new NotificationValidationException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.VALIDATION_FAILED,
|
||||
FailureCategory.INVALID_PAYLOAD)));
|
||||
List<NotificationSnapshot.RecipientSnapshot> jobs = new ArrayList<>();
|
||||
for (RecipientDeliveryRecord job : requests.recipientsOf(notificationId)) {
|
||||
List<NotificationSnapshot.AttemptSnapshot> attemptViews =
|
||||
attempts.attemptsOf(job.id()).stream()
|
||||
.map(NotificationSubmissionService::toAttemptSnapshot)
|
||||
.toList();
|
||||
jobs.add(
|
||||
new NotificationSnapshot.RecipientSnapshot(
|
||||
job.id(),
|
||||
job.state(),
|
||||
job.submissionOutcome(),
|
||||
job.deliveryOutcome(),
|
||||
job.evidenceLevel(),
|
||||
job.ambiguousAttemptExists(),
|
||||
job.duplicateRisk(),
|
||||
job.attemptCount(),
|
||||
attemptViews));
|
||||
}
|
||||
return new NotificationSnapshot(
|
||||
request.id(),
|
||||
request.tenantId(),
|
||||
request.status(),
|
||||
request.createdAt(),
|
||||
request.updatedAt(),
|
||||
jobs);
|
||||
});
|
||||
{
|
||||
{
|
||||
NotificationRequestRecord request =
|
||||
requests
|
||||
.findById(tenant, notificationId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new NotificationValidationException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.VALIDATION_FAILED,
|
||||
FailureCategory.INVALID_PAYLOAD)));
|
||||
List<NotificationSnapshot.RecipientSnapshot> jobs = new ArrayList<>();
|
||||
for (RecipientDeliveryRecord job : requests.recipientsOf(tenant, notificationId)) {
|
||||
List<NotificationSnapshot.AttemptSnapshot> attemptViews =
|
||||
attempts.attemptsOf(job.id()).stream()
|
||||
.map(NotificationSubmissionService::toAttemptSnapshot)
|
||||
.toList();
|
||||
jobs.add(
|
||||
new NotificationSnapshot.RecipientSnapshot(
|
||||
job.id(),
|
||||
job.state(),
|
||||
job.submissionOutcome(),
|
||||
job.deliveryOutcome(),
|
||||
job.evidenceLevel(),
|
||||
job.ambiguousAttemptExists(),
|
||||
job.duplicateRisk(),
|
||||
job.attemptCount(),
|
||||
attemptViews));
|
||||
}
|
||||
return new NotificationSnapshot(
|
||||
request.id(),
|
||||
request.tenantId(),
|
||||
request.status(),
|
||||
request.createdAt(),
|
||||
request.updatedAt(),
|
||||
jobs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static NotificationSnapshot.AttemptSnapshot toAttemptSnapshot(
|
||||
@@ -245,35 +388,4 @@ public final class NotificationSubmissionService implements NotificationOrchestr
|
||||
attempt.startedAt(),
|
||||
attempt.completedAt());
|
||||
}
|
||||
|
||||
private static boolean isTerminal(RecipientDeliveryState state) {
|
||||
return switch (state) {
|
||||
case COMPLETED, FAILED, EXPIRED, CANCELED, SUPPRESSED -> true;
|
||||
case PENDING, READY_TO_DISPATCH, DISPATCHING, RETRY_WAITING, RECONCILIATION_REQUIRED -> false;
|
||||
};
|
||||
}
|
||||
|
||||
/** Status the request should hold given its recipient jobs. */
|
||||
public static RequestStatus rollUp(List<RecipientDeliveryState> states) {
|
||||
Objects.requireNonNull(states, "states");
|
||||
if (states.isEmpty()) {
|
||||
return RequestStatus.CREATED;
|
||||
}
|
||||
boolean allTerminal = states.stream().allMatch(NotificationSubmissionService::isTerminal);
|
||||
boolean anyCompleted = states.contains(RecipientDeliveryState.COMPLETED);
|
||||
if (!allTerminal) {
|
||||
return RequestStatus.PROCESSING;
|
||||
}
|
||||
if (states.stream().allMatch(state -> state == RecipientDeliveryState.CANCELED)) {
|
||||
return RequestStatus.CANCELED;
|
||||
}
|
||||
if (states.stream().allMatch(state -> state == RecipientDeliveryState.EXPIRED)) {
|
||||
return RequestStatus.EXPIRED;
|
||||
}
|
||||
if (anyCompleted
|
||||
&& states.stream().anyMatch(state -> state != RecipientDeliveryState.COMPLETED)) {
|
||||
return RequestStatus.PARTIALLY_COMPLETED;
|
||||
}
|
||||
return anyCompleted ? RequestStatus.COMPLETED : RequestStatus.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.api.RecipientSpec;
|
||||
import dev.caskeleton.application.notification.platform.api.TenantId;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.DeliveryStrategy;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.ExplicitChannel;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.OrderedFallback;
|
||||
import dev.caskeleton.application.notification.platform.policy.RouteCandidate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Which routes a recipient is eligible for, and in what order.
|
||||
*
|
||||
* <p>This was in the outbound notification adapter. Deciding that a recipient's stated channel
|
||||
* preference outranks the strategy's default order, and that a blocked channel stays in the plan as
|
||||
* an ineligible entry rather than vanishing from it, are product decisions — they were being made
|
||||
* beside the code that speaks SMTP.
|
||||
*
|
||||
* <p>The adapter keeps {@link ProviderProfileCatalogPort}: which profile serves which channel,
|
||||
* which is configuration and genuinely its own.
|
||||
*/
|
||||
public final class PolicyRoutePlanner implements NotificationRoutePlannerPort {
|
||||
|
||||
private final ProviderProfileCatalogPort catalog;
|
||||
|
||||
public PolicyRoutePlanner(ProviderProfileCatalogPort catalog) {
|
||||
this.catalog = Objects.requireNonNull(catalog, "catalog");
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RouteCandidate> plan(
|
||||
TenantId tenantId, RecipientSpec recipient, DeliveryStrategy strategy) {
|
||||
Objects.requireNonNull(tenantId, "tenantId");
|
||||
Objects.requireNonNull(recipient, "recipient");
|
||||
Objects.requireNonNull(strategy, "strategy");
|
||||
|
||||
List<Channel> strategyOrder =
|
||||
switch (strategy) {
|
||||
case ExplicitChannel explicit -> List.of(explicit.channel());
|
||||
case OrderedFallback fallback -> fallback.channels();
|
||||
};
|
||||
List<Channel> ordered = applyPreferredOrder(strategyOrder, recipient);
|
||||
|
||||
List<RouteCandidate> routes = new ArrayList<>(ordered.size());
|
||||
int index = 0;
|
||||
for (Channel channel : ordered) {
|
||||
Optional<ProviderProfileId> profileId = catalog.profileFor(channel);
|
||||
if (profileId.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
Optional<dev.caskeleton.application.notification.platform.api.ContactPointSelector> selector =
|
||||
recipient.contactPoints().stream()
|
||||
.filter(candidate -> candidate.channel() == channel)
|
||||
.findFirst();
|
||||
if (selector.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
// A blocked channel stays in the plan and is marked ineligible rather than being removed:
|
||||
// "we did not try SMS because the recipient blocked it" and "we never had an SMS route" are
|
||||
// different answers, and an operator reading a stalled delivery needs to tell them apart.
|
||||
boolean blocked =
|
||||
recipient
|
||||
.channelOverride()
|
||||
.map(override -> override.blockedChannels().contains(channel))
|
||||
.orElse(false);
|
||||
routes.add(
|
||||
new RouteCandidate(
|
||||
index++, channel, selector.get().contactPointId(), profileId.get(), !blocked, true));
|
||||
}
|
||||
return List.copyOf(routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the recipient's preferred channels first, keeping the strategy's order for the rest.
|
||||
*
|
||||
* <p>An intersection, not a replacement: a preference names channels the recipient would rather
|
||||
* receive on, and a channel the strategy never offered is not made available by preferring it.
|
||||
*
|
||||
* @param strategyOrder the order the caller asked for
|
||||
* @param recipient the recipient, whose override may reorder it
|
||||
* @return the effective order
|
||||
*/
|
||||
private static List<Channel> applyPreferredOrder(
|
||||
List<Channel> strategyOrder, RecipientSpec recipient) {
|
||||
List<Channel> preferred =
|
||||
recipient
|
||||
.channelOverride()
|
||||
.map(ChannelPreferenceOverride::preferredOrder)
|
||||
.orElse(List.of());
|
||||
if (preferred.isEmpty()) {
|
||||
return strategyOrder;
|
||||
}
|
||||
List<Channel> ordered = new ArrayList<>(strategyOrder.size());
|
||||
preferred.stream().filter(strategyOrder::contains).forEach(ordered::add);
|
||||
strategyOrder.stream().filter(channel -> !ordered.contains(channel)).forEach(ordered::add);
|
||||
return List.copyOf(ordered);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Which provider profile is configured for a channel.
|
||||
*
|
||||
* <p>The whole of what the adapter knows and the application does not. Everything else the route
|
||||
* planner was doing — honouring the recipient's preferred channel order, applying blocked channels,
|
||||
* walking the strategy's fallback — is product policy, and it was being decided in the outbound
|
||||
* adapter where no application test could reach it.
|
||||
*/
|
||||
public interface ProviderProfileCatalogPort {
|
||||
|
||||
/**
|
||||
* The profile configured to serve a channel.
|
||||
*
|
||||
* @param channel the channel
|
||||
* @return the profile, or empty when the deployment configured none
|
||||
*/
|
||||
Optional<ProviderProfileId> profileFor(Channel channel);
|
||||
}
|
||||
+2
@@ -29,6 +29,7 @@ public record RecipientDeliveryRecord(
|
||||
boolean ambiguousAttemptExists,
|
||||
boolean duplicateRisk,
|
||||
Optional<Instant> nextDispatchAt,
|
||||
Optional<Instant> expiresAt,
|
||||
Optional<String> leaseOwner,
|
||||
Optional<Instant> leaseUntil,
|
||||
int attemptCount,
|
||||
@@ -38,6 +39,7 @@ public record RecipientDeliveryRecord(
|
||||
|
||||
public RecipientDeliveryRecord {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
Objects.requireNonNull(notificationId, "notificationId");
|
||||
Objects.requireNonNull(tenantId, "tenantId");
|
||||
Objects.requireNonNull(recipientRef, "recipientRef");
|
||||
|
||||
+22
-2
@@ -4,9 +4,25 @@ import dev.caskeleton.application.notification.platform.api.RecipientDeliveryId;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/** A claimed recipient job. Exactly one holder at a time. */
|
||||
/**
|
||||
* A claimed recipient job. Exactly one holder at a time, and the fence is what makes that true.
|
||||
*
|
||||
* <p>Identity alone is not ownership. A renew that matched only on the job id let a worker whose
|
||||
* lease had already expired — and whose job another worker had since claimed — write its own owner
|
||||
* and expiry back over the new holder's. Both workers then believed they held the job, and both
|
||||
* sent the notification.
|
||||
*
|
||||
* <p>The fence increases on every claim, so a late renew from a previous holder carries a fence
|
||||
* that no longer exists and updates nothing. It travels with the lease rather than being re-read,
|
||||
* because re-reading is the race.
|
||||
*
|
||||
* @param recipientDeliveryId the job this lease is for
|
||||
* @param owner the holder, including the process incarnation that claimed it
|
||||
* @param fence the claim generation; a lower fence has been superseded
|
||||
* @param leaseUntil when this lease stops being valid
|
||||
*/
|
||||
public record RecipientLease(
|
||||
RecipientDeliveryId recipientDeliveryId, String owner, Instant leaseUntil) {
|
||||
RecipientDeliveryId recipientDeliveryId, String owner, long fence, Instant leaseUntil) {
|
||||
|
||||
public RecipientLease {
|
||||
Objects.requireNonNull(recipientDeliveryId, "recipientDeliveryId");
|
||||
@@ -15,5 +31,9 @@ public record RecipientLease(
|
||||
if (owner.isBlank()) {
|
||||
throw new IllegalArgumentException("owner");
|
||||
}
|
||||
if (fence < 1) {
|
||||
// A zero fence is the value an unclaimed row holds; a lease can never legitimately carry it.
|
||||
throw new IllegalArgumentException("fence");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+35
-4
@@ -3,24 +3,55 @@ package dev.caskeleton.application.notification.platform.dispatch;
|
||||
import dev.caskeleton.application.notification.platform.api.RecipientDeliveryId;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Durable queue claim.
|
||||
*
|
||||
* <p>Implementations use {@code FOR UPDATE SKIP LOCKED} so two workers never claim the same job and
|
||||
* a crashed worker's lease simply expires instead of needing a coordinator.
|
||||
*
|
||||
* <p>Every operation after the claim is fenced. Expiry alone does not make a holder aware that it
|
||||
* has been replaced: a worker paused past its lease resumes believing it still holds the job, and
|
||||
* the only thing that can tell it otherwise is a write that fails. So renew returns an {@link
|
||||
* Optional} rather than a lease — an empty result means the lease is gone, and the caller must stop
|
||||
* rather than carry on with a token the database no longer honours.
|
||||
*/
|
||||
public interface RecipientLeaseStorePort {
|
||||
|
||||
/** Claim up to {@code limit} due jobs for a worker. */
|
||||
List<RecipientLease> claim(String workerId, int limit, Duration leaseDuration);
|
||||
|
||||
/** Extend a lease that is still being worked on. */
|
||||
RecipientLease renew(RecipientLease lease, Duration leaseDuration);
|
||||
/**
|
||||
* Extend a lease that is still being worked on.
|
||||
*
|
||||
* @return the extended lease, or empty when this holder no longer owns the job
|
||||
*/
|
||||
Optional<RecipientLease> renew(RecipientLease lease, Duration leaseDuration);
|
||||
|
||||
/** Release a lease without changing the job state. */
|
||||
void release(RecipientDeliveryId recipientDeliveryId, String workerId);
|
||||
/**
|
||||
* Release a lease without changing the job state.
|
||||
*
|
||||
* <p>Takes the whole lease, not just the id: releasing by id would let a superseded holder clear
|
||||
* the lease its replacement is actively working under.
|
||||
*/
|
||||
void release(RecipientLease lease);
|
||||
|
||||
/** Whether this lease is still the current one, for checking before an external side effect. */
|
||||
boolean stillHeld(RecipientLease lease);
|
||||
|
||||
/** Jobs left {@code DISPATCHING} by a worker that died, for reconciliation. */
|
||||
List<RecipientDeliveryId> expiredDispatching(int limit, Duration olderThan);
|
||||
|
||||
/**
|
||||
* Retire deliveries whose window closed before anyone claimed them.
|
||||
*
|
||||
* <p>The claim refuses them, which on its own leaves a queue that looks stuck. Retiring them to a
|
||||
* terminal state is what turns "never dispatched" from an absence into a fact an operator can
|
||||
* count.
|
||||
*
|
||||
* @param limit how many to retire in one pass
|
||||
* @return how many were retired
|
||||
*/
|
||||
int expireOverdue(int limit);
|
||||
}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A scheduled question: what happened to this attempt?
|
||||
*
|
||||
* <p>The table has existed since V3 with no entity, no store and no worker. Reconciliation ran only
|
||||
* when a lease expired and recovery happened to walk past the attempt — so an ambiguous submission
|
||||
* whose worker exited cleanly was never asked about again. It sat {@code RECONCILIATION_REQUIRED}
|
||||
* indefinitely, which looks like a queue that stopped rather than a delivery whose outcome nobody
|
||||
* knows.
|
||||
*
|
||||
* <p>{@code attemptId} is unique in the schema, so an attempt cannot accumulate a backlog of
|
||||
* duplicate questions: re-scheduling one that already exists moves its due time rather than adding
|
||||
* a second row.
|
||||
*
|
||||
* @param id the job identity
|
||||
* @param attemptId the attempt whose outcome is unknown
|
||||
* @param providerProfileId the profile that must answer, since capability is per profile
|
||||
* @param nextCheckAt when to ask next
|
||||
* @param attempts how many times this job has already asked
|
||||
* @param lastResult the previous answer, for operators reading the table directly
|
||||
* @param createdAt when the job was first scheduled
|
||||
* @param updatedAt when it last changed
|
||||
*/
|
||||
public record ReconciliationJob(
|
||||
UUID id,
|
||||
DeliveryAttemptId attemptId,
|
||||
ProviderProfileId providerProfileId,
|
||||
Instant nextCheckAt,
|
||||
int attempts,
|
||||
Optional<String> lastResult,
|
||||
Instant createdAt,
|
||||
Instant updatedAt) {
|
||||
|
||||
/** Validates the job. */
|
||||
public ReconciliationJob {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(attemptId, "attemptId");
|
||||
Objects.requireNonNull(providerProfileId, "providerProfileId");
|
||||
Objects.requireNonNull(nextCheckAt, "nextCheckAt");
|
||||
Objects.requireNonNull(lastResult, "lastResult");
|
||||
Objects.requireNonNull(createdAt, "createdAt");
|
||||
Objects.requireNonNull(updatedAt, "updatedAt");
|
||||
if (attempts < 0) {
|
||||
throw new IllegalArgumentException("attempts must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Where the outstanding "what happened to this attempt?" questions live.
|
||||
*
|
||||
* <p>Separate from the attempt row on purpose. An attempt records what one submission did; a job
|
||||
* records that the platform still owes an answer about it, and the two have different lifetimes —
|
||||
* the answer may arrive hours later, from a status query rather than from the submission path.
|
||||
*/
|
||||
public interface ReconciliationJobStorePort {
|
||||
|
||||
/**
|
||||
* Schedules a question about an attempt, or moves an existing one.
|
||||
*
|
||||
* <p>Idempotent by attempt: the schema makes {@code attempt_id} unique, so an attempt cannot
|
||||
* accumulate duplicate jobs however many times an ambiguous outcome is recorded for it.
|
||||
*
|
||||
* @param attemptId the attempt
|
||||
* @param providerProfileId the profile that must answer
|
||||
* @param dueAt when to ask
|
||||
* @return the scheduled job
|
||||
*/
|
||||
ReconciliationJob schedule(
|
||||
DeliveryAttemptId attemptId, ProviderProfileId providerProfileId, Instant dueAt);
|
||||
|
||||
/**
|
||||
* Claims due jobs for one worker pass.
|
||||
*
|
||||
* @param limit how many to take
|
||||
* @param now the current instant
|
||||
* @return the due jobs, oldest first
|
||||
*/
|
||||
List<ReconciliationJob> claimDue(int limit, Instant now);
|
||||
|
||||
/**
|
||||
* Records that a job was asked and answered inconclusively, and when to ask again.
|
||||
*
|
||||
* @param job the job
|
||||
* @param result a bounded description of the answer
|
||||
* @param nextCheckAt when to ask next
|
||||
*/
|
||||
void reschedule(ReconciliationJob job, String result, Instant nextCheckAt);
|
||||
|
||||
/**
|
||||
* Removes a job whose attempt now has a final outcome.
|
||||
*
|
||||
* @param job the job
|
||||
*/
|
||||
void complete(ReconciliationJob job);
|
||||
}
|
||||
+5
-15
@@ -33,6 +33,7 @@ public final class ReconciliationService {
|
||||
private final ProviderEventProjectionService projection;
|
||||
private final NotificationAuditPort audit;
|
||||
private final NotificationMetricsPort metrics;
|
||||
private final SyntheticEventFingerprint fingerprints;
|
||||
private final Clock clock;
|
||||
|
||||
public ReconciliationService(
|
||||
@@ -43,6 +44,7 @@ public final class ReconciliationService {
|
||||
ProviderEventProjectionService projection,
|
||||
NotificationAuditPort audit,
|
||||
NotificationMetricsPort metrics,
|
||||
SyntheticEventFingerprint fingerprints,
|
||||
Clock clock) {
|
||||
this.attempts = Objects.requireNonNull(attempts, "attempts");
|
||||
this.recipients = Objects.requireNonNull(recipients, "recipients");
|
||||
@@ -51,6 +53,7 @@ public final class ReconciliationService {
|
||||
this.projection = Objects.requireNonNull(projection, "projection");
|
||||
this.audit = Objects.requireNonNull(audit, "audit");
|
||||
this.metrics = Objects.requireNonNull(metrics, "metrics");
|
||||
this.fingerprints = Objects.requireNonNull(fingerprints, "fingerprints");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@@ -86,7 +89,8 @@ public final class ReconciliationService {
|
||||
clock.instant(),
|
||||
new byte[0],
|
||||
emptyDigest(),
|
||||
syntheticFingerprint(attempt, confirmed)));
|
||||
fingerprints.of(
|
||||
attempt.providerProfileId(), attempt.attemptId(), confirmed.event())));
|
||||
appended.newEvents().forEach(projection::project);
|
||||
audit.record(
|
||||
new NotificationAuditEvent(
|
||||
@@ -120,18 +124,4 @@ public final class ReconciliationService {
|
||||
private static String emptyDigest() {
|
||||
return "0".repeat(64);
|
||||
}
|
||||
|
||||
private static String syntheticFingerprint(
|
||||
DeliveryAttemptSnapshot attempt, ReconciliationResult.Confirmed confirmed) {
|
||||
String seed =
|
||||
attempt.attemptId().value()
|
||||
+ "|"
|
||||
+ confirmed.event().type().name()
|
||||
+ "|"
|
||||
+ confirmed.event().providerNativeType();
|
||||
return String.format(
|
||||
"%064x",
|
||||
new java.math.BigInteger(1, seed.getBytes(java.nio.charset.StandardCharsets.UTF_8)))
|
||||
.substring(0, 64);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-72
@@ -1,20 +1,16 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationPlan;
|
||||
import dev.caskeleton.application.notification.platform.api.RecipientSpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import dev.caskeleton.application.notification.platform.api.EncodedNotificationPlan;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Canonical SHA-256 fingerprint of a submission.
|
||||
* The idempotency fingerprint of a submission.
|
||||
*
|
||||
* <p>Everything that changes what the user receives is inside the fingerprint. That is what lets
|
||||
* the same idempotency key with a different amount be rejected as a conflict instead of silently
|
||||
* returning the receipt of the earlier, different notification.
|
||||
* <p>SHA-256 over the canonical encoding, and nothing else. Every question about what the
|
||||
* fingerprint covers — the whole fallback order, the channel override, the deduplication action,
|
||||
* the collapse spec, the schedule, the metadata, the typed variables — is a question about {@link
|
||||
* CanonicalNotificationPlanEncoder}, which is the single place a plan becomes bytes.
|
||||
*/
|
||||
public final class RequestFingerprint {
|
||||
|
||||
@@ -24,67 +20,19 @@ public final class RequestFingerprint {
|
||||
this.digest = Objects.requireNonNull(digest, "digest");
|
||||
}
|
||||
|
||||
/** Fingerprint of one plan. */
|
||||
public String of(NotificationPlan plan) {
|
||||
Objects.requireNonNull(plan, "plan");
|
||||
return HexFormat.of()
|
||||
.formatHex(digest.sha256(canonicalForm(plan).getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
private static String canonicalForm(NotificationPlan plan) {
|
||||
StringBuilder canonical = new StringBuilder(512);
|
||||
canonical
|
||||
.append("tenant=")
|
||||
.append(plan.tenantId().value())
|
||||
.append("\ncategory=")
|
||||
.append(plan.category())
|
||||
.append("\ntemplate=")
|
||||
.append(plan.template().templateId())
|
||||
.append('@')
|
||||
.append(plan.template().version())
|
||||
.append('/')
|
||||
.append(plan.template().locale().toLanguageTag())
|
||||
.append("\nstrategy=")
|
||||
.append(plan.deliveryStrategy().getClass().getSimpleName())
|
||||
.append(':')
|
||||
.append(plan.deliveryStrategy().primaryChannel())
|
||||
.append("\nnotBefore=")
|
||||
.append(plan.notBefore().map(Object::toString).orElse("-"))
|
||||
.append("\nexpiresAt=")
|
||||
.append(plan.expiresAt().map(Object::toString).orElse("-"))
|
||||
.append("\ndedup=")
|
||||
.append(plan.deduplication().map(spec -> spec.dedupKey() + "/" + spec.window()).orElse("-"))
|
||||
.append("\ncollapse=")
|
||||
.append(plan.collapse().map(spec -> spec.key() + "/" + spec.scope()).orElse("-"))
|
||||
.append("\nvariables=")
|
||||
.append(canonicalMap(plan.variables()))
|
||||
.append("\nmetadata=")
|
||||
.append(canonicalMap(plan.boundedMetadata()))
|
||||
.append("\nrecipients=")
|
||||
.append(
|
||||
plan.recipients().stream()
|
||||
.map(RequestFingerprint::canonicalRecipient)
|
||||
.collect(Collectors.joining(";")));
|
||||
return canonical.toString();
|
||||
}
|
||||
|
||||
private static String canonicalRecipient(RecipientSpec recipient) {
|
||||
return recipient.recipientRef()
|
||||
+ "|"
|
||||
+ recipient.locale().map(java.util.Locale::toLanguageTag).orElse("-")
|
||||
+ "|"
|
||||
+ recipient.timeZone().map(Object::toString).orElse("-")
|
||||
+ "|"
|
||||
+ recipient.contactPoints().stream()
|
||||
.map(selector -> selector.channel() + ":" + selector.contactPointId().value())
|
||||
.sorted()
|
||||
.collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
private static String canonicalMap(Map<String, ?> values) {
|
||||
return new TreeMap<>(values)
|
||||
.entrySet().stream()
|
||||
.map(entry -> entry.getKey() + "=" + entry.getValue())
|
||||
.collect(Collectors.joining(","));
|
||||
/**
|
||||
* Fingerprint of one encoded plan.
|
||||
*
|
||||
* <p>This class used to build its own canonical string while the stored variables payload was
|
||||
* produced separately by a JSON codec — two encodings of one request, in two places, with nothing
|
||||
* making them agree. It now hashes exactly the bytes {@link CanonicalNotificationPlanEncoder}
|
||||
* produced, and the persisted payload comes from that same pass.
|
||||
*
|
||||
* @param encoded the plan, encoded once
|
||||
* @return 64 lowercase hex characters
|
||||
*/
|
||||
public String of(EncodedNotificationPlan encoded) {
|
||||
Objects.requireNonNull(encoded, "encoded");
|
||||
return HexFormat.of().formatHex(digest.sha256(encoded.bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.capability.Idempotency;
|
||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationReceipt;
|
||||
import dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationCommand;
|
||||
import dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationUseCase;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.application.transaction.TransactionMode;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Accepting a plan for later dispatch.
|
||||
*
|
||||
* <p>Its own class, so its capability is its own. A single class implementing submit, schedule,
|
||||
* cancel and get cannot declare one honest transaction mode — the compiler says so too, because
|
||||
* {@code CommandUseCase} cannot be inherited twice with different type arguments. The mandatory
|
||||
* fitness gate reads these annotations; an orchestration that never implements the marker is simply
|
||||
* not checked, which is how this entrypoint bypassed it.
|
||||
*/
|
||||
@UseCaseCapability(
|
||||
transactionMode = TransactionMode.WRITE,
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||
@RequiresPermission("notification:submit")
|
||||
public final class ScheduleNotificationApplicationUseCase implements ScheduleNotificationUseCase {
|
||||
|
||||
private final NotificationSubmissionService service;
|
||||
private final TransactionPort transactions;
|
||||
|
||||
public ScheduleNotificationApplicationUseCase(
|
||||
NotificationSubmissionService service, TransactionPort transactions) {
|
||||
this.service = Objects.requireNonNull(service, "service");
|
||||
// Injected, and called directly below. A use case that declares it owns a repository-backed
|
||||
// transaction and then delegates the boundary to a collaborator is making a claim the
|
||||
// architecture gate cannot verify and a reader cannot trust.
|
||||
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationReceipt handle(ScheduleNotificationCommand command) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
NotificationSubmissionService.PreparedSubmission prepared =
|
||||
service.prepare(command.plan(), java.util.Optional.of(command.scheduleAt()));
|
||||
NotificationReceipt receipt =
|
||||
transactions.inWrite(() -> service.commit(command.plan(), prepared));
|
||||
service.recordAccepted(command.plan());
|
||||
return receipt;
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.capability.Idempotency;
|
||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationReceipt;
|
||||
import dev.caskeleton.application.notification.platform.port.in.SubmitNotificationCommand;
|
||||
import dev.caskeleton.application.notification.platform.port.in.SubmitNotificationUseCase;
|
||||
import dev.caskeleton.application.security.RequiresPermission;
|
||||
import dev.caskeleton.application.transaction.TransactionMode;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Accepting a plan for immediate dispatch.
|
||||
*
|
||||
* <p>Its own class, so its capability is its own. A single class implementing submit, schedule,
|
||||
* cancel and get cannot declare one honest transaction mode — the compiler says so too, because
|
||||
* {@code CommandUseCase} cannot be inherited twice with different type arguments. The mandatory
|
||||
* fitness gate reads these annotations; an orchestration that never implements the marker is simply
|
||||
* not checked, which is how this entrypoint bypassed it.
|
||||
*/
|
||||
@UseCaseCapability(
|
||||
transactionMode = TransactionMode.WRITE,
|
||||
idempotency = Idempotency.IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||
@RequiresPermission("notification:submit")
|
||||
public final class SubmitNotificationApplicationUseCase implements SubmitNotificationUseCase {
|
||||
|
||||
private final NotificationSubmissionService service;
|
||||
private final TransactionPort transactions;
|
||||
|
||||
public SubmitNotificationApplicationUseCase(
|
||||
NotificationSubmissionService service, TransactionPort transactions) {
|
||||
this.service = Objects.requireNonNull(service, "service");
|
||||
// Injected, and called directly below. A use case that declares it owns a repository-backed
|
||||
// transaction and then delegates the boundary to a collaborator is making a claim the
|
||||
// architecture gate cannot verify and a reader cannot trust.
|
||||
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationReceipt handle(SubmitNotificationCommand command) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
NotificationSubmissionService.PreparedSubmission prepared =
|
||||
service.prepare(command.plan(), java.util.Optional.empty());
|
||||
NotificationReceipt receipt =
|
||||
transactions.inWrite(() -> service.commit(command.plan(), prepared));
|
||||
service.recordAccepted(command.plan());
|
||||
return receipt;
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* The identity of an event the platform synthesised by asking a provider what happened.
|
||||
*
|
||||
* <p>It was not a hash. The seed string — attempt id, event type, native type joined by pipes — was
|
||||
* reinterpreted as a {@link java.math.BigInteger}, rendered as hex, and then truncated to its first
|
||||
* 64 characters. Hex renders each byte as two characters in order, so those 64 characters are the
|
||||
* first 32 <em>bytes of the seed</em>: the attempt's 36-character UUID and nothing else. The event
|
||||
* type and the native type were appended after the point where the value was cut off, and could not
|
||||
* affect the result.
|
||||
*
|
||||
* <p>The ledger enforces a unique fingerprint. So a reconciliation that learned an attempt had been
|
||||
* accepted, and a later one that learned the same attempt had been delivered, produced the same
|
||||
* fingerprint — and the second was silently discarded as a duplicate. The correction the platform
|
||||
* went and fetched was thrown away on arrival, and the delivery stayed at the earlier status
|
||||
* forever.
|
||||
*
|
||||
* <p>Every field is length-framed, so no value can be arranged to look like the boundary between
|
||||
* two others.
|
||||
*/
|
||||
public final class SyntheticEventFingerprint {
|
||||
|
||||
/**
|
||||
* Encoding version.
|
||||
*
|
||||
* <p>Inside the hashed bytes, so a future change to the field set produces different fingerprints
|
||||
* by construction rather than by anyone remembering to migrate.
|
||||
*/
|
||||
private static final String VERSION = "1";
|
||||
|
||||
private final MessageDigestPort digest;
|
||||
|
||||
public SyntheticEventFingerprint(MessageDigestPort digest) {
|
||||
this.digest = Objects.requireNonNull(digest, "digest");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerprint of one synthesised reconciliation event.
|
||||
*
|
||||
* @param providerProfileId the profile that answered
|
||||
* @param attemptId the attempt the answer is about
|
||||
* @param event the normalized answer
|
||||
* @return 64 lowercase hex characters
|
||||
*/
|
||||
public String of(
|
||||
ProviderProfileId providerProfileId,
|
||||
DeliveryAttemptId attemptId,
|
||||
NormalizedProviderEvent event) {
|
||||
Objects.requireNonNull(providerProfileId, "providerProfileId");
|
||||
Objects.requireNonNull(attemptId, "attemptId");
|
||||
Objects.requireNonNull(event, "event");
|
||||
return HexFormat.of()
|
||||
.formatHex(
|
||||
digest.sha256(
|
||||
canonicalForm(providerProfileId, attemptId, event)
|
||||
.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact bytes the fingerprint is taken over.
|
||||
*
|
||||
* @param providerProfileId the profile that answered
|
||||
* @param attemptId the attempt
|
||||
* @param event the normalized answer
|
||||
* @return the canonical, length-framed form
|
||||
*/
|
||||
private static String canonicalForm(
|
||||
ProviderProfileId providerProfileId,
|
||||
DeliveryAttemptId attemptId,
|
||||
NormalizedProviderEvent event) {
|
||||
StringBuilder canonical = new StringBuilder(256);
|
||||
field(canonical, "v", VERSION);
|
||||
field(canonical, "profile", providerProfileId.value());
|
||||
field(canonical, "attempt", attemptId.value().toString());
|
||||
field(canonical, "type", event.type().name());
|
||||
field(canonical, "nativeType", event.providerNativeType());
|
||||
// The provider's own timestamp, as epoch milliseconds rather than a rendered instant: the same
|
||||
// moment written as +00:00 and as Z is the same moment, and must not be two identities.
|
||||
field(
|
||||
canonical,
|
||||
"occurredAt",
|
||||
event
|
||||
.providerOccurredAt()
|
||||
.map(instant -> Long.toString(instant.toEpochMilli()))
|
||||
.orElse("-"));
|
||||
// The provider's stable identity for this event, when it has one. Two reconciliations that read
|
||||
// the same provider record are the same event; two that read different records are not, even
|
||||
// when every other field matches.
|
||||
field(canonical, "nativeId", event.providerEventId().orElse("-"));
|
||||
field(canonical, "requestId", event.providerRequestId().orElse("-"));
|
||||
return canonical.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one length-framed field.
|
||||
*
|
||||
* @param canonical the buffer
|
||||
* @param name the field name
|
||||
* @param value the field value
|
||||
*/
|
||||
private static void field(StringBuilder canonical, String name, String value) {
|
||||
canonical
|
||||
.append(name)
|
||||
.append(':')
|
||||
.append(value.length())
|
||||
.append(':')
|
||||
.append(value)
|
||||
.append('\n');
|
||||
}
|
||||
}
|
||||
+20
-4
@@ -1,13 +1,29 @@
|
||||
package dev.caskeleton.application.notification.platform.inbox;
|
||||
|
||||
/**
|
||||
* Delivers the post-commit signal.
|
||||
* Delivers the post-commit signal, best effort.
|
||||
*
|
||||
* <p>Publishing failures are retried by the relay and never roll back the inbox write: a WebSocket
|
||||
* outage should cost a live badge update, not the notification itself.
|
||||
* <p><strong>Not retried.</strong> The port used to say failures "are retried by the relay", and
|
||||
* nothing retried them: the only implementation publishes from an after-commit callback and
|
||||
* swallows the exception, so a single failed publish loses that signal permanently. There is no
|
||||
* durable record of the attempt for anything to replay.
|
||||
*
|
||||
* <p>The inbox row remains the source of truth, so what a lost signal actually costs is a client
|
||||
* that does not learn about the item until it next polls or reconnects. That is an acceptable
|
||||
* contract; claiming retry that does not exist is not, because an operator who believes signals are
|
||||
* durable will not build the reconciliation that makes them so.
|
||||
*
|
||||
* <p>Making this durable means writing the signal into an outbox inside the same application-owned
|
||||
* transaction as the inbox mutation and relaying from there. An after-commit callback cannot
|
||||
* provide durability — by the time it runs, the transaction that could have recorded the intent is
|
||||
* over.
|
||||
*/
|
||||
public interface NotificationInboxSignalPort {
|
||||
|
||||
/** Publish after the inbox transaction has committed. */
|
||||
/**
|
||||
* Publish after the inbox transaction has committed.
|
||||
*
|
||||
* <p>A failure here is not propagated to the caller and is not replayed.
|
||||
*/
|
||||
void publish(InboxItemCreated event);
|
||||
}
|
||||
|
||||
+106
-2
@@ -1,12 +1,29 @@
|
||||
package dev.caskeleton.application.notification.platform.observation;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Rejects metric tags outside the bounded vocabulary before they reach the metric backend. */
|
||||
/**
|
||||
* Bounds metric tags before they reach the metric backend — keys and values both.
|
||||
*
|
||||
* <p>The guard checked keys only. A key is a fixed list written in this file, so it was never the
|
||||
* unbounded side: {@code notificationCategory} is caller-supplied text, {@code templateId} grows
|
||||
* with the template catalogue, and a callback path is whatever a provider posted to. Each distinct
|
||||
* value creates a new time series in the metric backend, so a caller passing a request id as a
|
||||
* category converts one metric into a per-request one — which is how a metrics pipeline is brought
|
||||
* down by a system it is only observing.
|
||||
*
|
||||
* <p>Unknown values are folded to {@code other} rather than refused. Refusing would turn a
|
||||
* cardinality problem into a delivery failure, and the count is still correct — it just stops being
|
||||
* broken down by a dimension that was never going to be usable at that width.
|
||||
*/
|
||||
public final class CardinalityGuard {
|
||||
|
||||
/** Tag keys the platform emits. */
|
||||
private static final Set<String> ALLOWED_TAGS =
|
||||
Set.of(
|
||||
"channel",
|
||||
@@ -26,16 +43,103 @@ public final class CardinalityGuard {
|
||||
"attemptBucket",
|
||||
"sizeBucket");
|
||||
|
||||
/**
|
||||
* The value every unrecognised value becomes.
|
||||
*
|
||||
* <p>One extra series per tag, whatever arrives.
|
||||
*/
|
||||
public static final String OTHER = "other";
|
||||
|
||||
/**
|
||||
* The shape a bounded value has: an enum constant, a bucket name or number, or a short alias.
|
||||
*
|
||||
* <p>Bounded in length as well as in alphabet, because a 200-character SCREAMING_SNAKE string is
|
||||
* still a distinct series. This is only the cheap first filter — the per-tag ceiling below is
|
||||
* what actually caps the series count, because a short value can still be caller-controlled.
|
||||
*/
|
||||
private static final Pattern BOUNDED_VALUE = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_.+-]{0,31}");
|
||||
|
||||
/**
|
||||
* How many distinct values one tag may carry before the rest fold to {@link #OTHER}.
|
||||
*
|
||||
* <p>A ceiling rather than an allowlist, because the platform cannot know the deployment's
|
||||
* template ids or provider profile names in advance. The first values seen are the ones that get
|
||||
* their own series; a burst of new ones does not multiply the series count.
|
||||
*/
|
||||
private static final int MAX_VALUES_PER_TAG = 64;
|
||||
|
||||
private final Map<String, Set<String>> observed = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
/** Allowed tag keys. */
|
||||
public Set<String> allowedTags() {
|
||||
return ALLOWED_TAGS;
|
||||
}
|
||||
|
||||
/** Validate a tag map. */
|
||||
/**
|
||||
* Validate a tag map's keys.
|
||||
*
|
||||
* @param tags the tags
|
||||
* @throws IllegalMetricTagException when a key is outside the vocabulary
|
||||
*/
|
||||
public void validate(Map<String, String> tags) {
|
||||
Objects.requireNonNull(tags, "tags");
|
||||
if (!ALLOWED_TAGS.containsAll(tags.keySet())) {
|
||||
throw new IllegalMetricTagException(tags.keySet());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the keys and bound the values.
|
||||
*
|
||||
* @param tags the tags as the caller supplied them
|
||||
* @return the tags with every unbounded value folded to {@link #OTHER}
|
||||
* @throws IllegalMetricTagException when a key is outside the vocabulary
|
||||
*/
|
||||
public Map<String, String> bound(Map<String, String> tags) {
|
||||
validate(tags);
|
||||
Map<String, String> bounded = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, String> tag : tags.entrySet()) {
|
||||
bounded.put(tag.getKey(), boundValue(tag.getKey(), tag.getValue()));
|
||||
}
|
||||
return Map.copyOf(bounded);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value this tag will actually carry.
|
||||
*
|
||||
* @param key the tag key
|
||||
* @param value the caller's value
|
||||
* @return the value, or {@link #OTHER}
|
||||
*/
|
||||
private String boundValue(String key, String value) {
|
||||
if (value == null || !BOUNDED_VALUE.matcher(value).matches()) {
|
||||
return OTHER;
|
||||
}
|
||||
if (SensitiveValueDetector.isSensitive(value)) {
|
||||
// A tag is a label on a series that is kept for as long as the metric is; a recipient's
|
||||
// address must not become one.
|
||||
return OTHER;
|
||||
}
|
||||
Set<String> values =
|
||||
observed.computeIfAbsent(key, unused -> java.util.concurrent.ConcurrentHashMap.newKeySet());
|
||||
if (values.contains(value)) {
|
||||
return value;
|
||||
}
|
||||
if (values.size() >= MAX_VALUES_PER_TAG) {
|
||||
return OTHER;
|
||||
}
|
||||
values.add(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many distinct values a tag has been allowed so far.
|
||||
*
|
||||
* @param key the tag key
|
||||
* @return the count, which never exceeds the per-tag ceiling
|
||||
*/
|
||||
public int observedValueCount(String key) {
|
||||
Set<String> values = observed.get(Objects.requireNonNull(key, "key").toLowerCase(Locale.ROOT));
|
||||
return values == null ? observed.getOrDefault(key, Set.of()).size() : values.size();
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -5,7 +5,15 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** An audited administrative or lifecycle change. Contact point values never appear here. */
|
||||
/**
|
||||
* An audited administrative or lifecycle change.
|
||||
*
|
||||
* <p>Contact point values never appear here, and now the record is what makes that true rather than
|
||||
* this sentence. Every string it carries goes through {@link SensitiveValueDetector}: an address, a
|
||||
* phone number, a token or a one-time code is refused at construction. The audit trail is
|
||||
* append-only and read by more people than any other store, so a value that reaches it is a value
|
||||
* that cannot be taken back.
|
||||
*/
|
||||
public record NotificationAuditEvent(
|
||||
String action,
|
||||
String actorRef,
|
||||
@@ -24,5 +32,12 @@ public record NotificationAuditEvent(
|
||||
if (action.isBlank() || actorRef.isBlank()) {
|
||||
throw new IllegalArgumentException("action and actorRef must not be blank");
|
||||
}
|
||||
SensitiveValueDetector.requireNotSensitive("actorRef", actorRef);
|
||||
reasonCode.ifPresent(value -> SensitiveValueDetector.requireNotSensitive("reasonCode", value));
|
||||
operationId.ifPresent(
|
||||
value -> SensitiveValueDetector.requireNotSensitive("operationId", value));
|
||||
for (Map.Entry<String, String> attribute : boundedAttributes.entrySet()) {
|
||||
SensitiveValueDetector.requireNotSensitive(attribute.getKey(), attribute.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package dev.caskeleton.application.notification.platform.observation;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* What the platform is actually doing right now, measured rather than assumed.
|
||||
*
|
||||
* <p>The health snapshot reported an empty queue map and the scheduler gauged the size of the batch
|
||||
* it had just claimed. Both are always small and always available, so a platform whose backlog was
|
||||
* growing without bound, whose leases were stuck on a dead worker, or whose projections had stopped
|
||||
* being applied reported exactly the same numbers as an idle healthy one. Readiness that cannot
|
||||
* distinguish those two states is not readiness.
|
||||
*
|
||||
* @param backlogDepth deliveries due now and not yet claimed
|
||||
* @param oldestDueAge how long the oldest of them has been due
|
||||
* @param stuckLeases leases whose holder has not finished and whose expiry has passed
|
||||
* @param pendingProjections provider events stored and not yet applied
|
||||
* @param failedProjections provider events whose projection failed
|
||||
* @param unmatchedProjections provider events that match no known attempt
|
||||
* @param oldestPendingProjectionAge how long the oldest unapplied event has waited
|
||||
* @param reconciliationDue outstanding questions to providers
|
||||
* @param oldestReconciliationAge how long the oldest of them has waited
|
||||
*/
|
||||
public record NotificationServingState(
|
||||
long backlogDepth,
|
||||
Duration oldestDueAge,
|
||||
long stuckLeases,
|
||||
long pendingProjections,
|
||||
long failedProjections,
|
||||
long unmatchedProjections,
|
||||
Duration oldestPendingProjectionAge,
|
||||
long reconciliationDue,
|
||||
Duration oldestReconciliationAge) {
|
||||
|
||||
/** An idle platform: nothing waiting, nothing stuck. */
|
||||
public static final NotificationServingState IDLE =
|
||||
new NotificationServingState(0, Duration.ZERO, 0, 0, 0, 0, Duration.ZERO, 0, Duration.ZERO);
|
||||
|
||||
public NotificationServingState {
|
||||
Objects.requireNonNull(oldestDueAge, "oldestDueAge");
|
||||
Objects.requireNonNull(oldestPendingProjectionAge, "oldestPendingProjectionAge");
|
||||
Objects.requireNonNull(oldestReconciliationAge, "oldestReconciliationAge");
|
||||
if (backlogDepth < 0
|
||||
|| stuckLeases < 0
|
||||
|| pendingProjections < 0
|
||||
|| failedProjections < 0
|
||||
|| unmatchedProjections < 0
|
||||
|| reconciliationDue < 0) {
|
||||
throw new IllegalArgumentException("counts must not be negative");
|
||||
}
|
||||
if (oldestDueAge.isNegative()
|
||||
|| oldestPendingProjectionAge.isNegative()
|
||||
|| oldestReconciliationAge.isNegative()) {
|
||||
throw new IllegalArgumentException("ages must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.caskeleton.application.notification.platform.observation;
|
||||
|
||||
/** Reads the platform's current serving state from wherever the durable rows live. */
|
||||
public interface NotificationServingStatePort {
|
||||
|
||||
/**
|
||||
* Measure the backlog, the stuck leases and the projection and reconciliation lag.
|
||||
*
|
||||
* @return the current state
|
||||
*/
|
||||
NotificationServingState currentState();
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package dev.caskeleton.application.notification.platform.observation;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Recognises values that must never be written to a log, a metric tag, or an audit row.
|
||||
*
|
||||
* <p>{@code NotificationAuditEvent} carried the sentence "Contact point values never appear here"
|
||||
* and nothing that made it true. The whole platform encrypts contact points at rest and hashes them
|
||||
* for lookup; an audit trail that records the plaintext beside the operation undoes that, in the
|
||||
* one store that is deliberately append-only and widely readable.
|
||||
*
|
||||
* <p>The check is a refusal rather than a redaction. Silently rewriting the value would leave the
|
||||
* caller believing it recorded something it did not, and the caller passing a recipient's address
|
||||
* into an audit attribute is the defect — masking it hides the defect rather than fixing it.
|
||||
*/
|
||||
public final class SensitiveValueDetector {
|
||||
|
||||
/** An address; the local part is deliberately loose because the point is the shape. */
|
||||
private static final Pattern EMAIL = Pattern.compile("[^\\s@]+@[^\\s@]+\\.[^\\s@]+");
|
||||
|
||||
/** E.164 and the common national forms — 7 or more digits with optional separators. */
|
||||
private static final Pattern PHONE = Pattern.compile("\\+?\\d[\\d\\-. ()]{5,}\\d");
|
||||
|
||||
/** A run of digits long enough to be a one-time code, an account number, or a card. */
|
||||
private static final Pattern DIGIT_RUN = Pattern.compile("\\d{6,}");
|
||||
|
||||
/** A compact JWT. */
|
||||
private static final Pattern JWT =
|
||||
Pattern.compile("eyJ[A-Za-z0-9_-]{4,}\\.[A-Za-z0-9_-]{4,}\\.[A-Za-z0-9_-]*");
|
||||
|
||||
/** PEM-encoded key material. */
|
||||
private static final Pattern PEM = Pattern.compile("-----BEGIN [A-Z ]*(KEY|CERTIFICATE)-----");
|
||||
|
||||
/** A long unbroken base64 or hex run, which is what raw key material looks like. */
|
||||
private static final Pattern KEY_MATERIAL = Pattern.compile("[A-Za-z0-9+/=]{40,}");
|
||||
|
||||
/** Words that introduce a secret even when the value itself looks innocuous. */
|
||||
private static final Pattern SECRET_PREFIX =
|
||||
Pattern.compile(
|
||||
"(?i)\\b(bearer|basic|password|passwd|secret|api[_-]?key|otp|token)\\b\\s*[:=]?\\s*\\S");
|
||||
|
||||
private SensitiveValueDetector() {}
|
||||
|
||||
/**
|
||||
* Whether a value looks like something that must not be recorded.
|
||||
*
|
||||
* @param value the candidate, which may be null
|
||||
* @return true when the value matches a contact, credential, or one-time-code shape
|
||||
*/
|
||||
public static boolean isSensitive(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String candidate = value.trim();
|
||||
return EMAIL.matcher(candidate).find()
|
||||
|| PHONE.matcher(candidate).find()
|
||||
|| DIGIT_RUN.matcher(candidate).find()
|
||||
|| JWT.matcher(candidate).find()
|
||||
|| PEM.matcher(candidate).find()
|
||||
|| KEY_MATERIAL.matcher(candidate).find()
|
||||
|| SECRET_PREFIX.matcher(candidate).find();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a value that must not be recorded.
|
||||
*
|
||||
* @param field which field is being checked, for the message
|
||||
* @param value the candidate
|
||||
* @return the value, when it is safe to record
|
||||
* @throws IllegalArgumentException when the value looks like a contact point, credential, or code
|
||||
*/
|
||||
public static String requireNotSensitive(String field, String value) {
|
||||
if (isSensitive(value)) {
|
||||
// The offending value is deliberately absent from the message: an exception message is itself
|
||||
// logged, so naming the value here would leak it through the check meant to prevent the leak.
|
||||
throw new IllegalArgumentException(
|
||||
"audit field '"
|
||||
+ field.toLowerCase(Locale.ROOT)
|
||||
+ "' looks like a contact point, credential, or one-time code; record a reference "
|
||||
+ "(an id, a hash, or a key id) instead of the value");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.application.notification.platform.port.in;
|
||||
|
||||
import dev.caskeleton.application.command.Command;
|
||||
import dev.caskeleton.application.notification.platform.api.CancelCommand;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationId;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Stop future logical attempts for a notification.
|
||||
*
|
||||
* @param notificationId which notification
|
||||
* @param command the cancellation request
|
||||
*/
|
||||
public record CancelNotificationCommand(NotificationId notificationId, CancelCommand command)
|
||||
implements Command {
|
||||
|
||||
public CancelNotificationCommand {
|
||||
Objects.requireNonNull(notificationId, "notificationId");
|
||||
Objects.requireNonNull(command, "command");
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.application.notification.platform.port.in;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.CancelResult;
|
||||
import dev.caskeleton.application.usecase.CommandUseCase;
|
||||
|
||||
/**
|
||||
* Cancelling a notification.
|
||||
*
|
||||
* <p>A write, and one that cannot promise what a caller might assume: an attempt already committed
|
||||
* to a provider cannot be recalled, so the result reports what was actually stopped.
|
||||
*/
|
||||
public interface CancelNotificationUseCase
|
||||
extends CommandUseCase<CancelNotificationCommand, CancelResult> {}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.application.notification.platform.port.in;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationId;
|
||||
import dev.caskeleton.application.query.Query;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Read the current projection of a notification.
|
||||
*
|
||||
* @param notificationId which notification
|
||||
*/
|
||||
public record GetNotificationQuery(NotificationId notificationId) implements Query {
|
||||
|
||||
public GetNotificationQuery {
|
||||
Objects.requireNonNull(notificationId, "notificationId");
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.application.notification.platform.port.in;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationSnapshot;
|
||||
import dev.caskeleton.application.usecase.QueryUseCase;
|
||||
|
||||
/**
|
||||
* Reading a notification's current state.
|
||||
*
|
||||
* <p>A {@code QueryUseCase}, so the fitness gate holds it to {@code READ_ONLY} and {@code
|
||||
* READ_REPOSITORY}. Sharing an interface with three writes made that impossible to state.
|
||||
*/
|
||||
public interface GetNotificationUseCase
|
||||
extends QueryUseCase<GetNotificationQuery, NotificationSnapshot> {}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.application.notification.platform.port.in;
|
||||
|
||||
import dev.caskeleton.application.command.Command;
|
||||
import dev.caskeleton.application.notification.platform.api.CallbackRequest;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* One provider callback, as a command.
|
||||
*
|
||||
* @param request the verified-on-arrival callback request
|
||||
*/
|
||||
public record IngestProviderCallbackCommand(CallbackRequest request) implements Command {
|
||||
|
||||
/** Validates the command. */
|
||||
public IngestProviderCallbackCommand {
|
||||
Objects.requireNonNull(request, "request");
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.application.notification.platform.port.in;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.CallbackIngestionResult;
|
||||
import dev.caskeleton.application.notification.platform.api.CallbackRequest;
|
||||
import dev.caskeleton.application.usecase.CommandUseCase;
|
||||
|
||||
/**
|
||||
* The inbound port a callback endpoint depends on.
|
||||
*
|
||||
* <p>The MVC controller injected {@code IngestProviderCallbackApplicationUseCase} — a concrete
|
||||
* application class — so the transport was coupled to an implementation and the platform's
|
||||
* mandatory use-case fitness gate never saw the entrypoint. That gate is what declares transaction
|
||||
* mode, repository access, idempotency and whether external outbound calls are permitted; an
|
||||
* orchestration that does not implement the marker simply is not checked, and nothing noticed
|
||||
* because the existing ArchUnit rules only inspect types that already implement it.
|
||||
*
|
||||
* <p>One operation, not four. {@code NotificationOrchestrator} mixes submit, schedule, cancel and
|
||||
* get on one interface, which makes a class-level capability declaration impossible to state
|
||||
* honestly: a read and a write cannot share one transaction mode.
|
||||
*/
|
||||
public interface IngestProviderCallbackUseCase
|
||||
extends CommandUseCase<IngestProviderCallbackCommand, CallbackIngestionResult> {
|
||||
|
||||
/**
|
||||
* Ingests one verified provider callback.
|
||||
*
|
||||
* @param command the callback to ingest
|
||||
* @return how many events were appended and how many were duplicates
|
||||
*/
|
||||
@Override
|
||||
CallbackIngestionResult handle(IngestProviderCallbackCommand command);
|
||||
|
||||
/**
|
||||
* Convenience for callers holding a raw request.
|
||||
*
|
||||
* @param request the callback
|
||||
* @return the ingestion result
|
||||
*/
|
||||
default CallbackIngestionResult ingest(CallbackRequest request) {
|
||||
return handle(new IngestProviderCallbackCommand(request));
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.application.notification.platform.port.in;
|
||||
|
||||
import dev.caskeleton.application.command.Command;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationPlan;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Accept a plan that must not be activated before a given moment.
|
||||
*
|
||||
* @param plan the submission intent
|
||||
* @param scheduleAt the earliest dispatch time
|
||||
*/
|
||||
public record ScheduleNotificationCommand(NotificationPlan plan, Instant scheduleAt)
|
||||
implements Command {
|
||||
|
||||
public ScheduleNotificationCommand {
|
||||
Objects.requireNonNull(plan, "plan");
|
||||
Objects.requireNonNull(scheduleAt, "scheduleAt");
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.application.notification.platform.port.in;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationReceipt;
|
||||
import dev.caskeleton.application.usecase.CommandUseCase;
|
||||
|
||||
/** Accepting a notification for later dispatch. */
|
||||
public interface ScheduleNotificationUseCase
|
||||
extends CommandUseCase<ScheduleNotificationCommand, NotificationReceipt> {}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.application.notification.platform.port.in;
|
||||
|
||||
import dev.caskeleton.application.command.Command;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationPlan;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Accept a plan for immediate dispatch.
|
||||
*
|
||||
* @param plan the submission intent
|
||||
*/
|
||||
public record SubmitNotificationCommand(NotificationPlan plan) implements Command {
|
||||
|
||||
public SubmitNotificationCommand {
|
||||
Objects.requireNonNull(plan, "plan");
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.application.notification.platform.port.in;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationReceipt;
|
||||
import dev.caskeleton.application.usecase.CommandUseCase;
|
||||
|
||||
/**
|
||||
* Accepting a notification for immediate dispatch.
|
||||
*
|
||||
* <p>One operation per port. {@code NotificationOrchestrator} carries submit, schedule, cancel and
|
||||
* get on one interface, and a class implementing all four cannot declare a class-level capability
|
||||
* that is true: {@code get} is a read and {@code submit} is a write, so any single transaction mode
|
||||
* misstates one of them. Splitting them is what lets the mandatory fitness gate check each.
|
||||
*/
|
||||
public interface SubmitNotificationUseCase
|
||||
extends CommandUseCase<SubmitNotificationCommand, NotificationReceipt> {}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package dev.caskeleton.application.notification.platform.provider;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Thrown when a provider call failed before a single byte reached the wire.
|
||||
*
|
||||
* <p>The dispatch path converted <em>every</em> {@code RuntimeException} from the gateway into
|
||||
* {@code responseLost()} — body committed, outcome ambiguous. Most of the exceptions that path can
|
||||
* throw happen before any network call: a disabled runtime, an exhausted rate limiter or
|
||||
* concurrency permit, a contact point that could not be revealed, a payload that failed mapping or
|
||||
* exceeded the provider's size limit, a profile with no credential.
|
||||
*
|
||||
* <p>Recording those as "may already have been sent" is expensive in one direction only. An
|
||||
* ambiguous attempt is deliberately never retried automatically and never falls back to another
|
||||
* channel, because doing so risks a duplicate. So a rate-limiter rejection — a condition that will
|
||||
* clear in a second — permanently blocked the delivery it rejected and sent it into a
|
||||
* reconciliation that can only ever return "the provider has no record of this", forever.
|
||||
*
|
||||
* <p>An adapter throws this when it knows the transport had not started. Anything it does not know
|
||||
* stays ambiguous: this type exists to narrow the ambiguous case honestly, not to shrink it by
|
||||
* assumption.
|
||||
*/
|
||||
public final class ProviderCallNotStartedException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String code;
|
||||
|
||||
private final FailureCategory category;
|
||||
|
||||
private final boolean retryable;
|
||||
|
||||
/**
|
||||
* Creates a pre-write failure.
|
||||
*
|
||||
* @param code the platform failure code
|
||||
* @param category the failure category
|
||||
* @param retryable whether the caller may attempt this delivery again
|
||||
* @param sanitizedMessage a message carrying no recipient or payload content
|
||||
*/
|
||||
public ProviderCallNotStartedException(
|
||||
String code, FailureCategory category, boolean retryable, String sanitizedMessage) {
|
||||
super(sanitizedMessage);
|
||||
this.code = Objects.requireNonNull(code, "code");
|
||||
if (code.isBlank()) {
|
||||
throw new IllegalArgumentException("code must not be blank");
|
||||
}
|
||||
this.category = Objects.requireNonNull(category, "category");
|
||||
this.retryable = retryable;
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform failure code.
|
||||
*
|
||||
* @return the code
|
||||
*/
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure category.
|
||||
*
|
||||
* @return the category
|
||||
*/
|
||||
public FailureCategory category() {
|
||||
return category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the delivery may be attempted again.
|
||||
*
|
||||
* @return true when a retry is safe
|
||||
*/
|
||||
public boolean retryable() {
|
||||
return retryable;
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure as the platform records it.
|
||||
*
|
||||
* @return the provider failure
|
||||
*/
|
||||
public ProviderFailure toFailure() {
|
||||
return ProviderFailure.of(code, category, retryable);
|
||||
}
|
||||
}
|
||||
+54
@@ -40,5 +40,59 @@ public record ProviderSubmission(
|
||||
approvedNativeOptions =
|
||||
Map.copyOf(Objects.requireNonNull(approvedNativeOptions, "approvedNativeOptions"));
|
||||
Objects.requireNonNull(traceContext, "traceContext");
|
||||
requireCompatible(channel, profile, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a submission whose channel, profile and rendered content disagree.
|
||||
*
|
||||
* <p>Nothing checked this, so a webhook submission could carry email content and a test could
|
||||
* construct one that no real dispatch would ever produce — which is how an adapter suite comes to
|
||||
* pass against a shape the platform cannot emit. The channel decides which content type is
|
||||
* meaningful, and the profile is bound to one channel, so both agreements are structural rather
|
||||
* than a matter of convention.
|
||||
*/
|
||||
private static void requireCompatible(
|
||||
Channel channel,
|
||||
ProviderProfileSnapshot profile,
|
||||
dev.caskeleton.application.notification.platform.template.RenderedNotificationContent
|
||||
content) {
|
||||
if (profile.channel() != channel) {
|
||||
throw new IllegalArgumentException(
|
||||
"profile "
|
||||
+ profile.profileId().value()
|
||||
+ " serves "
|
||||
+ profile.channel()
|
||||
+ ", not "
|
||||
+ channel);
|
||||
}
|
||||
var payload = content.content();
|
||||
boolean matches =
|
||||
switch (channel) {
|
||||
case EMAIL ->
|
||||
payload
|
||||
instanceof
|
||||
dev.caskeleton.application.notification.platform.api.content.EmailContent;
|
||||
case SMS ->
|
||||
payload
|
||||
instanceof
|
||||
dev.caskeleton.application.notification.platform.api.content.SmsContent;
|
||||
case PUSH ->
|
||||
payload
|
||||
instanceof
|
||||
dev.caskeleton.application.notification.platform.api.content.MobilePushContent;
|
||||
case WEB_PUSH ->
|
||||
payload
|
||||
instanceof
|
||||
dev.caskeleton.application.notification.platform.api.content.WebPushContent;
|
||||
case IN_APP, WEBHOOK ->
|
||||
payload
|
||||
instanceof
|
||||
dev.caskeleton.application.notification.platform.api.content.InAppContent;
|
||||
};
|
||||
if (!matches) {
|
||||
throw new IllegalArgumentException(
|
||||
"channel " + channel + " cannot carry " + payload.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-1
@@ -8,5 +8,22 @@ public enum SecretPurpose {
|
||||
PROVIDER_CREDENTIAL,
|
||||
VAPID_SIGNING,
|
||||
UNSUBSCRIBE_TOKEN,
|
||||
PAYLOAD_ENCRYPTION
|
||||
PAYLOAD_ENCRYPTION,
|
||||
|
||||
/**
|
||||
* Hashing a provider's request id for the callback lookup.
|
||||
*
|
||||
* <p>Its own purpose because it was sharing {@link #CONTACT_LOOKUP_HMAC}. Purpose separation is
|
||||
* the property that keeps one compromised key from being useful elsewhere, and reusing the
|
||||
* contact-lookup key here meant an attacker able to forge one lookup could forge the other.
|
||||
*/
|
||||
PROVIDER_REQUEST_LOOKUP_HMAC,
|
||||
|
||||
/**
|
||||
* Fingerprinting a callback payload for deduplication.
|
||||
*
|
||||
* <p>Separated for the same reason: a fingerprint key and a contact-lookup key protect different
|
||||
* things and should not fall together.
|
||||
*/
|
||||
CALLBACK_FINGERPRINT_HMAC
|
||||
}
|
||||
|
||||
+4
-1
@@ -37,7 +37,10 @@ public record OutboxEvent(
|
||||
Objects.requireNonNull(eventId, "eventId");
|
||||
Objects.requireNonNull(eventType, "eventType");
|
||||
Objects.requireNonNull(aggregateId, "aggregateId");
|
||||
Objects.requireNonNull(payload, "payload");
|
||||
// Not merely non-null: the envelope serialiser inserts this verbatim and unescaped, so an
|
||||
// invalid or oversized payload becomes a permanently unparseable message that the relay
|
||||
// retries forever. See OutboxPayloadPolicy.
|
||||
OutboxPayloadPolicy.requireValidPayload(payload);
|
||||
Objects.requireNonNull(occurredAt, "occurredAt");
|
||||
Objects.requireNonNull(correlationId, "correlationId");
|
||||
Objects.requireNonNull(idempotencyKey, "idempotencyKey");
|
||||
|
||||
+19
@@ -17,4 +17,23 @@ public interface OutboxMessagePublishPort {
|
||||
* @throws RuntimeException if the publish fails for any reason
|
||||
*/
|
||||
void publish(OutboxEvent event);
|
||||
|
||||
/**
|
||||
* Publishes and reports what was achieved.
|
||||
*
|
||||
* <p>The throwing method above cannot express an ambiguous publish: a broker that accepted the
|
||||
* frame and never confirmed it is reported the same way as one that refused it outright, and the
|
||||
* relay then treats a possibly-stored message as definitely-unstored. An adapter that knows the
|
||||
* difference implements this; the default keeps the fail-closed contract for one that does not.
|
||||
*
|
||||
* @param event the claimed outbox event
|
||||
* @return what the attempt achieved
|
||||
*/
|
||||
default OutboxPublishOutcome publishForOutcome(OutboxEvent event) {
|
||||
publish(event);
|
||||
// A method that either returns or throws can only report these two. Reporting CONFIRMED here
|
||||
// is honest for such an adapter — it is what "returned without throwing" has always meant —
|
||||
// and an adapter that can tell an ambiguous publish apart overrides this.
|
||||
return OutboxPublishOutcome.CONFIRMED;
|
||||
}
|
||||
}
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
package dev.caskeleton.application.outbox;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* What an outbox payload has to be before a row is written.
|
||||
*
|
||||
* <p>{@code OutboxEvent} checked that the payload was non-null and nothing else, and the envelope
|
||||
* serialiser then inserted it into the JSON document verbatim, unescaped, because it is documented
|
||||
* as "already valid JSON". Nothing enforced that. A payload that was not valid JSON produced a
|
||||
* malformed envelope that the broker accepted and every consumer failed to parse — permanently,
|
||||
* because the row is durable and the relay retries it forever. A payload containing {@code
|
||||
* "},"eventType":"} rewrote the envelope's own fields.
|
||||
*
|
||||
* <p>Size was equally unbounded. The row goes into a database column, through a broker with a
|
||||
* message limit, and into a JSON log line; a payload larger than any of those is a failure
|
||||
* discovered after the transaction committed, which is the one point at which it can no longer be
|
||||
* rejected.
|
||||
*
|
||||
* <p>This is a validator, not a parser: it walks the text and returns nothing. Building a tree
|
||||
* would mean a JSON library on {@code application-core}'s classpath, which the module's contract
|
||||
* forbids, and would allocate a second copy of every payload for a check that needs neither.
|
||||
*/
|
||||
public final class OutboxPayloadPolicy {
|
||||
|
||||
/**
|
||||
* The payload ceiling in UTF-8 bytes.
|
||||
*
|
||||
* <p>256 KiB: comfortably under the one mebibyte default of every broker in the platform, with
|
||||
* room for the envelope's own fields and for a log line that quotes it.
|
||||
*/
|
||||
public static final int MAX_PAYLOAD_BYTES = 262_144;
|
||||
|
||||
private OutboxPayloadPolicy() {}
|
||||
|
||||
/**
|
||||
* Refuses a payload that is not a single valid JSON value, or is too large.
|
||||
*
|
||||
* @param payload the serialised payload
|
||||
* @throws IllegalArgumentException naming what is wrong and where
|
||||
*/
|
||||
public static void requireValidPayload(String payload) {
|
||||
if (payload == null) {
|
||||
throw new IllegalArgumentException("outbox payload must not be null");
|
||||
}
|
||||
int bytes = payload.getBytes(StandardCharsets.UTF_8).length;
|
||||
if (bytes > MAX_PAYLOAD_BYTES) {
|
||||
throw new IllegalArgumentException(
|
||||
"outbox payload is "
|
||||
+ bytes
|
||||
+ " UTF-8 bytes; the limit is "
|
||||
+ MAX_PAYLOAD_BYTES
|
||||
+ ". Rejecting it here is the last point at which it can be rejected at all — after"
|
||||
+ " the row commits, the relay retries it forever.");
|
||||
}
|
||||
Scanner scanner = new Scanner(payload);
|
||||
scanner.skipWhitespace();
|
||||
scanner.readValue(0);
|
||||
scanner.skipWhitespace();
|
||||
if (!scanner.exhausted()) {
|
||||
throw new IllegalArgumentException(
|
||||
"outbox payload has trailing content at offset "
|
||||
+ scanner.position
|
||||
+ "; it must be exactly one JSON value");
|
||||
}
|
||||
}
|
||||
|
||||
/** A single-pass structural JSON validator over the payload text. */
|
||||
private static final class Scanner {
|
||||
|
||||
/**
|
||||
* The nesting ceiling.
|
||||
*
|
||||
* <p>Present because this validator recurses: without it a payload of ten thousand open
|
||||
* brackets overflows the stack of whatever thread appends the row.
|
||||
*/
|
||||
private static final int MAX_DEPTH = 64;
|
||||
|
||||
private final String text;
|
||||
private int position;
|
||||
|
||||
private Scanner(String text) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
private boolean exhausted() {
|
||||
return position >= text.length();
|
||||
}
|
||||
|
||||
private void skipWhitespace() {
|
||||
while (position < text.length()) {
|
||||
char current = text.charAt(position);
|
||||
if (current == ' ' || current == '\t' || current == '\n' || current == '\r') {
|
||||
position++;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void readValue(int depth) {
|
||||
if (depth > MAX_DEPTH) {
|
||||
throw fail("nesting deeper than " + MAX_DEPTH + " levels");
|
||||
}
|
||||
if (exhausted()) {
|
||||
throw fail("a JSON value was expected");
|
||||
}
|
||||
char current = text.charAt(position);
|
||||
switch (current) {
|
||||
case '{' -> readObject(depth);
|
||||
case '[' -> readArray(depth);
|
||||
case '"' -> readString();
|
||||
case 't' -> readLiteral("true");
|
||||
case 'f' -> readLiteral("false");
|
||||
case 'n' -> readLiteral("null");
|
||||
default -> readNumber();
|
||||
}
|
||||
}
|
||||
|
||||
private void readObject(int depth) {
|
||||
expect('{');
|
||||
skipWhitespace();
|
||||
if (peekIs('}')) {
|
||||
position++;
|
||||
return;
|
||||
}
|
||||
while (true) {
|
||||
skipWhitespace();
|
||||
readString();
|
||||
skipWhitespace();
|
||||
expect(':');
|
||||
skipWhitespace();
|
||||
readValue(depth + 1);
|
||||
skipWhitespace();
|
||||
if (peekIs(',')) {
|
||||
position++;
|
||||
continue;
|
||||
}
|
||||
expect('}');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void readArray(int depth) {
|
||||
expect('[');
|
||||
skipWhitespace();
|
||||
if (peekIs(']')) {
|
||||
position++;
|
||||
return;
|
||||
}
|
||||
while (true) {
|
||||
skipWhitespace();
|
||||
readValue(depth + 1);
|
||||
skipWhitespace();
|
||||
if (peekIs(',')) {
|
||||
position++;
|
||||
continue;
|
||||
}
|
||||
expect(']');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void readString() {
|
||||
expect('"');
|
||||
while (true) {
|
||||
if (exhausted()) {
|
||||
throw fail("an unterminated string");
|
||||
}
|
||||
char current = text.charAt(position++);
|
||||
if (current == '"') {
|
||||
return;
|
||||
}
|
||||
if (current == '\\') {
|
||||
readEscape();
|
||||
} else if (current < 0x20) {
|
||||
// A raw control character inside a string is invalid JSON, and it is also exactly what
|
||||
// turns one log line into two.
|
||||
throw fail("a raw control character inside a string");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void readEscape() {
|
||||
if (exhausted()) {
|
||||
throw fail("an escape at the end of the payload");
|
||||
}
|
||||
char escaped = text.charAt(position++);
|
||||
switch (escaped) {
|
||||
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't' -> {
|
||||
// A complete escape.
|
||||
}
|
||||
case 'u' -> {
|
||||
if (position + 4 > text.length()) {
|
||||
throw fail("a truncated unicode escape");
|
||||
}
|
||||
for (int index = 0; index < 4; index++) {
|
||||
char digit = text.charAt(position++);
|
||||
boolean hexadecimal =
|
||||
(digit >= '0' && digit <= '9')
|
||||
|| (digit >= 'a' && digit <= 'f')
|
||||
|| (digit >= 'A' && digit <= 'F');
|
||||
if (!hexadecimal) {
|
||||
throw fail("a unicode escape that is not hexadecimal");
|
||||
}
|
||||
}
|
||||
}
|
||||
default -> throw fail("an unknown escape \\" + escaped);
|
||||
}
|
||||
}
|
||||
|
||||
private void readNumber() {
|
||||
int start = position;
|
||||
if (peekIs('-')) {
|
||||
position++;
|
||||
}
|
||||
readDigits();
|
||||
if (peekIs('.')) {
|
||||
position++;
|
||||
readDigits();
|
||||
}
|
||||
if (peekIs('e') || peekIs('E')) {
|
||||
position++;
|
||||
if (peekIs('+') || peekIs('-')) {
|
||||
position++;
|
||||
}
|
||||
readDigits();
|
||||
}
|
||||
if (position == start) {
|
||||
throw fail("a JSON value was expected");
|
||||
}
|
||||
}
|
||||
|
||||
private void readDigits() {
|
||||
int start = position;
|
||||
while (position < text.length()
|
||||
&& text.charAt(position) >= '0'
|
||||
&& text.charAt(position) <= '9') {
|
||||
position++;
|
||||
}
|
||||
if (position == start) {
|
||||
throw fail("a number with no digits");
|
||||
}
|
||||
}
|
||||
|
||||
private void readLiteral(String literal) {
|
||||
if (!text.startsWith(literal, position)) {
|
||||
throw fail("an unrecognised literal");
|
||||
}
|
||||
position += literal.length();
|
||||
}
|
||||
|
||||
private boolean peekIs(char expected) {
|
||||
return position < text.length() && text.charAt(position) == expected;
|
||||
}
|
||||
|
||||
private void expect(char expected) {
|
||||
if (!peekIs(expected)) {
|
||||
throw fail("'" + expected + "' was expected");
|
||||
}
|
||||
position++;
|
||||
}
|
||||
|
||||
private IllegalArgumentException fail(String what) {
|
||||
// Deliberately reports the offset and not the text: the payload is business content and this
|
||||
// message reaches logs.
|
||||
return new IllegalArgumentException(
|
||||
"outbox payload is not valid JSON: " + what + " at offset " + position);
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package dev.caskeleton.application.outbox;
|
||||
|
||||
/**
|
||||
* What a publish attempt achieved, in the application's own vocabulary.
|
||||
*
|
||||
* <p>The port could only say "returned" or "threw". A broker that accepted a frame and then failed
|
||||
* to confirm it is neither: the message may be stored, so retrying it may duplicate and not
|
||||
* retrying it may lose. Collapsing that into an exception forces the relay to choose one of those
|
||||
* two wrong answers, and it chooses the same one every time.
|
||||
*
|
||||
* <p>Four values, because two of them are the ones that matter operationally and the naming trap
|
||||
* between the two outbox models is real:
|
||||
*
|
||||
* <ul>
|
||||
* <li>the legacy {@code OutboxEventStatus.FAILED} is <em>retryable</em> and {@code DEAD} is
|
||||
* terminal;
|
||||
* <li>the platform's {@code OutboxStatus.FAILED} is a <em>definite rejection</em> and terminal,
|
||||
* while {@code AMBIGUOUS} is the retryable one.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Mapping those by name inverts both meanings: a definitely-rejected message would be retried
|
||||
* forever and an unknown one would be parked. This type exists so no bridge is ever tempted to map
|
||||
* enum names.
|
||||
*/
|
||||
public enum OutboxPublishOutcome {
|
||||
|
||||
/** The broker acknowledged the message. Safe to mark published. */
|
||||
CONFIRMED,
|
||||
|
||||
/**
|
||||
* The message left this process and no acknowledgement arrived.
|
||||
*
|
||||
* <p>Retryable only under an idempotency key the broker or the consumer honours; the row stays
|
||||
* claimable and the duplicate risk is explicit rather than hidden inside an exception.
|
||||
*/
|
||||
AMBIGUOUS,
|
||||
|
||||
/**
|
||||
* Refused before anything was transmitted — validation, routing, an unknown destination.
|
||||
*
|
||||
* <p>Definite: nothing was stored anywhere, so a retry of the same payload fails the same way.
|
||||
* This is what the legacy model calls {@code DEAD}, not what it calls {@code FAILED}.
|
||||
*/
|
||||
REJECTED_BEFORE_SEND,
|
||||
|
||||
/**
|
||||
* The broker received the message and refused it.
|
||||
*
|
||||
* <p>Also definite, but distinct from {@link #REJECTED_BEFORE_SEND}: the message reached the
|
||||
* broker, so an operator investigating looks at broker-side policy — quota, ACL, message size —
|
||||
* rather than at this application's routing.
|
||||
*/
|
||||
REJECTED_AFTER_BROKER;
|
||||
|
||||
/** True when the relay may claim this row again. */
|
||||
public boolean retryable() {
|
||||
return this == AMBIGUOUS;
|
||||
}
|
||||
|
||||
/** True when nothing was transmitted, so no duplicate can exist. */
|
||||
public boolean nothingTransmitted() {
|
||||
return this == REJECTED_BEFORE_SEND;
|
||||
}
|
||||
}
|
||||
+23
-4
@@ -9,6 +9,14 @@ import java.util.Objects;
|
||||
*
|
||||
* <p>The report deliberately cannot carry an event payload, idempotency key, rendered message,
|
||||
* severity, arbitrary fields, or the whole {@link OutboxEvent}.
|
||||
*
|
||||
* <p>It also cannot carry the exception. It used to, and the renderer passed that {@code Throwable}
|
||||
* to the logger as the event's cause — so the operational JSON gained a message and a stack trace
|
||||
* this allowlist had no say over. A driver's exception text routinely contains the endpoint it was
|
||||
* connecting to, the statement it was running, and occasionally the credential it was using. What
|
||||
* survives is the exception's class name and, when the exception carries a platform code, that one:
|
||||
* enough to group failures and route a runbook, and nothing that is business content. The bounded
|
||||
* code is {@link #code()}, which this allowlist already owned.
|
||||
*/
|
||||
public record OutboxRelayFailureReport(
|
||||
OperationalError code,
|
||||
@@ -18,7 +26,7 @@ public record OutboxRelayFailureReport(
|
||||
String correlationId,
|
||||
int attemptCount,
|
||||
Instant nextAttemptAt,
|
||||
RuntimeException cause) {
|
||||
String causeType) {
|
||||
|
||||
public OutboxRelayFailureReport {
|
||||
Objects.requireNonNull(code, "code must not be null");
|
||||
@@ -26,7 +34,7 @@ public record OutboxRelayFailureReport(
|
||||
requireNonBlank(eventType, "eventType");
|
||||
requireNonBlank(aggregateId, "aggregateId");
|
||||
requireNonBlank(correlationId, "correlationId");
|
||||
Objects.requireNonNull(cause, "cause must not be null");
|
||||
requireNonBlank(causeType, "causeType");
|
||||
if (attemptCount < 1) {
|
||||
throw new IllegalArgumentException("attemptCount must be >= 1, was " + attemptCount);
|
||||
}
|
||||
@@ -59,7 +67,7 @@ public record OutboxRelayFailureReport(
|
||||
correlationId,
|
||||
attemptCount,
|
||||
nextAttemptAt,
|
||||
cause);
|
||||
causeTypeOf(cause));
|
||||
}
|
||||
|
||||
public static OutboxRelayFailureReport deadLetter(
|
||||
@@ -77,7 +85,18 @@ public record OutboxRelayFailureReport(
|
||||
correlationId,
|
||||
attemptCount,
|
||||
null,
|
||||
cause);
|
||||
causeTypeOf(cause));
|
||||
}
|
||||
|
||||
/**
|
||||
* The exception's class name, which is a type and not content.
|
||||
*
|
||||
* @param cause the failure
|
||||
* @return the fully qualified class name
|
||||
*/
|
||||
private static String causeTypeOf(RuntimeException cause) {
|
||||
Objects.requireNonNull(cause, "cause must not be null");
|
||||
return cause.getClass().getName();
|
||||
}
|
||||
|
||||
private static void requireNonBlank(String value, String name) {
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.application.transaction;
|
||||
|
||||
/**
|
||||
* What reconciliation concluded about a transaction whose commit outcome was unknown (design
|
||||
* §17.4).
|
||||
*
|
||||
* <p>{@link #STILL_UNKNOWN} is a legitimate answer, and keeping it is the point. Forcing a binary
|
||||
* result would push the resolver into guessing, and a wrong guess either duplicates a payment or
|
||||
* loses one.
|
||||
*/
|
||||
public enum CompletionResolution {
|
||||
|
||||
/** Evidence shows the transaction committed; the use case must not be re-run. */
|
||||
COMMITTED,
|
||||
|
||||
/** Evidence shows the transaction did not commit; the use case may be re-run. */
|
||||
NOT_COMMITTED,
|
||||
|
||||
/** No conclusive evidence; the record stays in the reconciliation queue. */
|
||||
STILL_UNKNOWN
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package dev.caskeleton.application.transaction;
|
||||
|
||||
/**
|
||||
* Marks that the current attempt performed an effect that cannot be undone by a rollback (design
|
||||
* §19.2).
|
||||
*
|
||||
* <p>Database rollback only reverses database work. A use case that sent an email, charged a card,
|
||||
* published to a broker, or wrote to object storage has already changed the world, and re-running
|
||||
* it does it twice. A use case that cannot avoid such an effect calls {@link #mark()} before
|
||||
* performing it; the retry policy then refuses to re-run that attempt no matter what the failure
|
||||
* was.
|
||||
*
|
||||
* <p>In application-core because the use case is what calls {@link #mark()}, immediately before the
|
||||
* effect it cannot undo. While this lived in the persistence adapter, marking an irreversible
|
||||
* effect required a use case to import that adapter, so the one call site that matters was the one
|
||||
* the architecture forbade.
|
||||
*
|
||||
* <p>The right fix is usually to move the effect out of the transaction entirely — the design
|
||||
* forbids waiting on external calls inside a DB transaction. This flag exists for the cases where
|
||||
* that refactor has not happened yet, so the unsafe retry is prevented rather than merely
|
||||
* documented.
|
||||
*/
|
||||
public final class IrreversibleSideEffectContext {
|
||||
|
||||
private static final ThreadLocal<Boolean> PERFORMED = new ThreadLocal<>();
|
||||
|
||||
private IrreversibleSideEffectContext() {}
|
||||
|
||||
/** Declares that this attempt has performed an effect a rollback cannot reverse. */
|
||||
public static void mark() {
|
||||
PERFORMED.set(Boolean.TRUE);
|
||||
}
|
||||
|
||||
/** Whether the current attempt declared an irreversible effect. */
|
||||
public static boolean performed() {
|
||||
return Boolean.TRUE.equals(PERFORMED.get());
|
||||
}
|
||||
|
||||
/** Clears the marker; the retry coordinator calls this between attempts. */
|
||||
public static void clear() {
|
||||
PERFORMED.remove();
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.caskeleton.application.transaction;
|
||||
|
||||
/**
|
||||
* The domain-specific SPI that decides whether an unknown transaction actually committed (design
|
||||
* §17.4).
|
||||
*
|
||||
* <p>An outbound SPI in application-core, not in the persistence adapter. It used to live beside
|
||||
* the JPA transaction engine while its own documentation said the domain implements it — which the
|
||||
* domain cannot do without importing an outbound adapter, inverting the dependency. Declaring it
|
||||
* here means the adapter depends on the contract and the domain implements it, both pointing the
|
||||
* same way.
|
||||
*
|
||||
* <p>The core cannot implement this. Only the domain knows which unique constraint, idempotency
|
||||
* record, business row, or outbox entry proves the write happened, so the platform provides the
|
||||
* question and the domain provides the evidence.
|
||||
*
|
||||
* @param <K> the application's transaction key type
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface TransactionCompletionResolver<K> {
|
||||
|
||||
/**
|
||||
* Resolves one unknown transaction against domain evidence.
|
||||
*
|
||||
* <p>Implementations must read evidence only. Re-running the original use case from a resolver is
|
||||
* the exact duplicate-write this design forbids.
|
||||
*/
|
||||
CompletionResolution resolve(K transactionKey);
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package dev.caskeleton.application.notification.platform.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.lang.reflect.RecordComponent;
|
||||
import java.lang.reflect.Type;
|
||||
import java.lang.reflect.WildcardType;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Whether the public submission API still admits an untyped value anywhere.
|
||||
*
|
||||
* <p>{@code NotificationPlan.variables} was {@code Map<String, Object>}. Everything that followed
|
||||
* from that — a fingerprint computed through {@code toString()}, a nested mutable value the plan
|
||||
* could not copy, two encoders that only agreed by coincidence — was a consequence of one {@code
|
||||
* Object} in a record component. A single case fixed by hand comes back the next time somebody
|
||||
* needs a value the algebra does not have.
|
||||
*
|
||||
* <p>So the ban is structural: no record in the public API graph may carry {@code Object}, directly
|
||||
* or inside a generic argument, at any depth.
|
||||
*/
|
||||
class PublicApiIsTypedTest {
|
||||
|
||||
private static final String API_PACKAGE = "dev.caskeleton.application.notification.platform.api";
|
||||
|
||||
@Test
|
||||
@DisplayName("no public API record carries an untyped value at any depth")
|
||||
void noPublicApiRecordCarriesAnUntypedValue() {
|
||||
List<String> untyped = new ArrayList<>();
|
||||
|
||||
for (Class<?> type : apiRecords()) {
|
||||
for (RecordComponent component : type.getRecordComponents()) {
|
||||
if (mentionsObject(component.getGenericType())) {
|
||||
untyped.add(type.getSimpleName() + "." + component.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(untyped)
|
||||
.as(
|
||||
"an Object here is a value the fingerprint can only render with toString(), "
|
||||
+ "and a value the plan cannot copy")
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the test actually scans the API package rather than an empty list")
|
||||
void theTestActuallyScansSomething() {
|
||||
// A reflection sweep that finds no classes passes silently. This is the guard that stops the
|
||||
// rule above from becoming decoration if the package moves.
|
||||
assertThat(apiRecords())
|
||||
.as("the API package must contain records for the sweep above to mean anything")
|
||||
.hasSizeGreaterThan(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the variables map is the typed algebra")
|
||||
void theVariablesMapIsTheTypedAlgebra() {
|
||||
RecordComponent variables =
|
||||
Stream.of(NotificationPlan.class.getRecordComponents())
|
||||
.filter(component -> component.getName().equals("variables"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
assertThat(variables.getGenericType().getTypeName())
|
||||
.isEqualTo("java.util.Map<java.lang.String, " + NotificationVariable.class.getName() + ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a type mentions {@code Object} anywhere in its generic signature.
|
||||
*
|
||||
* @param type the type to inspect
|
||||
* @return true when {@code Object} appears, directly or as a type argument
|
||||
*/
|
||||
private static boolean mentionsObject(Type type) {
|
||||
if (type == Object.class) {
|
||||
return true;
|
||||
}
|
||||
if (type instanceof ParameterizedType parameterized) {
|
||||
for (Type argument : parameterized.getActualTypeArguments()) {
|
||||
if (mentionsObject(argument)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return mentionsObject(parameterized.getRawType());
|
||||
}
|
||||
if (type instanceof WildcardType wildcard) {
|
||||
// `? extends Object` is `?`, which is exactly as untyped as Object itself.
|
||||
return Stream.of(wildcard.getUpperBounds()).allMatch(bound -> bound == Object.class);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every record in the public API package tree.
|
||||
*
|
||||
* @return the records
|
||||
*/
|
||||
private static List<Class<?>> apiRecords() {
|
||||
List<Class<?>> records = new ArrayList<>();
|
||||
for (String name : classNamesUnder(API_PACKAGE)) {
|
||||
Class<?> type;
|
||||
try {
|
||||
type = Class.forName(name);
|
||||
} catch (ClassNotFoundException | NoClassDefFoundError unavailable) {
|
||||
continue;
|
||||
}
|
||||
if (type.isRecord() && java.lang.reflect.Modifier.isPublic(type.getModifiers())) {
|
||||
records.add(type);
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class names under a package, read from the compiled output directory.
|
||||
*
|
||||
* @param packageName the package root
|
||||
* @return every class name below it
|
||||
*/
|
||||
private static List<String> classNamesUnder(String packageName) {
|
||||
try {
|
||||
// Anchored on a class that lives in the package being scanned, so the root is the main
|
||||
// output directory whatever the test output layout is. Anchoring on this test class instead
|
||||
// pointed at the test output root, where the API package does not exist — and the sweep then
|
||||
// found nothing and passed. The vacuity guard below is what caught that.
|
||||
Path root =
|
||||
Path.of(
|
||||
NotificationPlan.class.getProtectionDomain().getCodeSource().getLocation().toURI());
|
||||
Path packageRoot = root.resolve(packageName.replace('.', '/'));
|
||||
if (!Files.isDirectory(packageRoot)) {
|
||||
return List.of();
|
||||
}
|
||||
try (Stream<Path> files = Files.walk(packageRoot)) {
|
||||
return files
|
||||
.filter(path -> path.toString().endsWith(".class"))
|
||||
.map(
|
||||
path ->
|
||||
packageName
|
||||
+ "."
|
||||
+ packageRoot
|
||||
.relativize(path)
|
||||
.toString()
|
||||
.replace(java.io.File.separatorChar, '.')
|
||||
.replaceAll("\\.class$", ""))
|
||||
.toList();
|
||||
}
|
||||
} catch (URISyntaxException | IOException unreadable) {
|
||||
throw new UncheckedIOException(new IOException("cannot scan " + packageName, unreadable));
|
||||
}
|
||||
}
|
||||
}
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
package dev.caskeleton.application.notification.platform.callback;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.CallbackRequest;
|
||||
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
|
||||
import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* What the callback endpoint promises the provider, and when.
|
||||
*
|
||||
* <p>The comment said "durable append, then a fast 2xx". The code appended and then projected
|
||||
* synchronously, so a projector that threw failed a callback the provider had already been told was
|
||||
* accepted — and the provider, seeing a 5xx, redelivered an event that was already in the ledger. A
|
||||
* slow projector held the provider's connection open for as long as it ran.
|
||||
*
|
||||
* <p>The append itself was not one unit either: events were inserted one at a time with no
|
||||
* transaction of this path's own, so a failure part way through a batch committed the events before
|
||||
* it and told the caller nothing about the split.
|
||||
*/
|
||||
class CallbackIngestionAtomicityTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-15T09:00:00Z");
|
||||
private static final ProviderId SES = new ProviderId("ses");
|
||||
private static final ProviderProfileId PROFILE = new ProviderProfileId("email-main");
|
||||
|
||||
private final RecordingLedger ledger = new RecordingLedger();
|
||||
private final RecordingTransactions transactions = new RecordingTransactions();
|
||||
|
||||
@Test
|
||||
@DisplayName("the append runs inside one transaction")
|
||||
void theAppendRunsInsideOneTransaction() {
|
||||
service(SES).ingest(request(SES));
|
||||
|
||||
assertThat(transactions.writes)
|
||||
.as("event-at-a-time appends could commit the first and lose the rest")
|
||||
.isEqualTo(1);
|
||||
assertThat(ledger.appended).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the endpoint returns once the event is durable, without projecting it")
|
||||
void theEndpointReturnsOnceTheEventIsDurable() {
|
||||
service(SES).ingest(request(SES));
|
||||
|
||||
assertThat(ledger.appended).as("the event is durable when the endpoint returns").hasSize(1);
|
||||
// The strongest available statement: the service has no projector to call. It used to take one
|
||||
// and invoke it inline, so a projector that threw failed a callback the provider had already
|
||||
// been told was accepted, and the provider redelivered an event already in the ledger.
|
||||
assertThat(IngestProviderCallbackApplicationUseCase.class.getDeclaredFields())
|
||||
.noneMatch(field -> field.getType().equals(ProviderEventProjectionService.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"a callback presented against another provider's profile is refused before verifying")
|
||||
void aMismatchedProviderIsRefusedBeforeVerifying() {
|
||||
RecordingAdapter adapter = new RecordingAdapter(new ProviderId("twilio"));
|
||||
|
||||
assertThatThrownBy(() -> service(adapter).ingest(request(SES)))
|
||||
.isInstanceOf(CallbackValidationException.class);
|
||||
assertThat(adapter.verifications)
|
||||
.as(
|
||||
"verifying would fetch that provider's certificate on an unauthenticated caller's"
|
||||
+ " say-so")
|
||||
.isZero();
|
||||
assertThat(ledger.appended).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a matching provider is verified and appended")
|
||||
void aMatchingProviderIsVerifiedAndAppended() {
|
||||
RecordingAdapter adapter = new RecordingAdapter(SES);
|
||||
|
||||
service(adapter).ingest(request(SES));
|
||||
|
||||
assertThat(adapter.verifications).isEqualTo(1);
|
||||
assertThat(ledger.appended).hasSize(1);
|
||||
}
|
||||
|
||||
private IngestProviderCallbackApplicationUseCase service(ProviderId adapterProvider) {
|
||||
return service(new RecordingAdapter(adapterProvider));
|
||||
}
|
||||
|
||||
private IngestProviderCallbackApplicationUseCase service(RecordingAdapter adapter) {
|
||||
return new IngestProviderCallbackApplicationUseCase(
|
||||
new SingleAdapterRegistry(adapter),
|
||||
ledger,
|
||||
transactions,
|
||||
new PassThroughProtection(),
|
||||
new DiscardingSecurityAudit(),
|
||||
new DiscardingMetrics(),
|
||||
Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private static CallbackRequest request(ProviderId providerId) {
|
||||
return new CallbackRequest(
|
||||
providerId,
|
||||
PROFILE,
|
||||
"https://callbacks.example/notifications/ses",
|
||||
"POST",
|
||||
Optional.of("application/json"),
|
||||
Map.of("content-type", List.of("application/json")),
|
||||
"{}".getBytes(java.nio.charset.StandardCharsets.UTF_8),
|
||||
NOW);
|
||||
}
|
||||
|
||||
/** Counts write boundaries, so "one transaction" is a fact rather than a claim. */
|
||||
private static final class RecordingTransactions implements TransactionPort {
|
||||
|
||||
private int writes;
|
||||
|
||||
@Override
|
||||
public <T> T inWrite(Supplier<T> action) {
|
||||
writes++;
|
||||
return action.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T inRootWrite(Supplier<T> action) {
|
||||
return inWrite(action);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T inRead(Supplier<T> action) {
|
||||
return action.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T inNew(Supplier<T> action) {
|
||||
return inWrite(action);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingAdapter implements ProviderCallbackAdapter {
|
||||
|
||||
private final ProviderId providerId;
|
||||
private int verifications;
|
||||
|
||||
private RecordingAdapter(ProviderId providerId) {
|
||||
this.providerId = providerId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderId providerId() {
|
||||
return providerId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CallbackVerificationResult verify(CallbackRequest request) {
|
||||
verifications++;
|
||||
return CallbackVerificationResult.valid(new VerifiedCallback(request, Map.of()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<NormalizedProviderEvent> normalize(VerifiedCallback callback) {
|
||||
return List.of(
|
||||
new NormalizedProviderEvent(
|
||||
NormalizedEventType.DELIVERY_CONFIRMED,
|
||||
"delivered",
|
||||
Optional.of("event-1"),
|
||||
Optional.of("request-1"),
|
||||
Optional.of(NOW),
|
||||
Map.of()));
|
||||
}
|
||||
}
|
||||
|
||||
private record SingleAdapterRegistry(RecordingAdapter adapter)
|
||||
implements ProviderCallbackAdapterRegistry {
|
||||
|
||||
@Override
|
||||
public ProviderCallbackAdapter require(ProviderProfileId profileId) {
|
||||
return adapter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CallbackLimits limitsFor(ProviderProfileId profileId) {
|
||||
return new CallbackLimits(65_536L, java.util.Set.of("application/json"));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingLedger implements ProviderEventLedger {
|
||||
|
||||
private final List<VerifiedProviderEvent> appended = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public AppendEventResult append(VerifiedProviderEvent event) {
|
||||
return appendAll(List.of(event));
|
||||
}
|
||||
|
||||
@Override
|
||||
public AppendEventResult appendAll(List<VerifiedProviderEvent> events) {
|
||||
appended.addAll(events);
|
||||
return new AppendEventResult(List.of(), List.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderEventRecord> pendingProjection(int limit) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markApplied(ProviderEventRecordId eventId, ProjectionResult result) {
|
||||
// Not part of this contract.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markFailed(ProviderEventRecordId eventId, String errorCode) {
|
||||
// Not part of this contract.
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderEventRecord> unmatched(int limit) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderEventRecord> bindUnmatched(
|
||||
ProviderProfileId providerProfileId,
|
||||
String providerRequestId,
|
||||
DeliveryAttemptId attemptId) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderEventRecord> eventsForAttempt(DeliveryAttemptId attemptId) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PassThroughProtection implements CallbackPayloadProtectionPort {
|
||||
|
||||
@Override
|
||||
public byte[] protectRawPayload(byte[] rawPayload) {
|
||||
return rawPayload;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String digest(byte[] rawPayload) {
|
||||
return "0".repeat(64);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String fingerprint(
|
||||
ProviderProfileId profileId, NormalizedProviderEvent event, String rawDigest) {
|
||||
return "1".repeat(64);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DiscardingSecurityAudit
|
||||
implements dev.caskeleton.application.notification.platform.observation
|
||||
.NotificationSecurityAuditPort {
|
||||
|
||||
@Override
|
||||
public void callbackSignatureRejected(ProviderProfileId profileId, String reasonCode) {
|
||||
// Not asserted here.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void callbackRejectedByLimit(ProviderProfileId profileId, String reasonCode) {
|
||||
// Not asserted here.
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DiscardingMetrics implements NotificationMetricsPort {
|
||||
|
||||
@Override
|
||||
public void increment(String metricName, Map<String, String> tags) {
|
||||
// Not asserted here.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void record(String metricName, Map<String, String> tags, java.time.Duration value) {
|
||||
// Not asserted here.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void gauge(String metricName, Map<String, String> tags, double value) {
|
||||
// Not asserted here.
|
||||
}
|
||||
}
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.EncodedNotificationPlan;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationPlan;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationVariable;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Whether two requests that differ produce two encodings.
|
||||
*
|
||||
* <p>The plan's variables were {@code Map<String, Object>} and the fingerprint rendered them with
|
||||
* {@code toString()}. So {@code "1"} and {@code 1} were the same variable, a value containing the
|
||||
* delimiter could imitate the boundary between two other fields, and a nested mutable value could
|
||||
* be changed after the hash was taken and before the payload was stored. Meanwhile the stored
|
||||
* payload came from a different encoder entirely, so nothing made the hashed bytes and the stored
|
||||
* bytes describe the same request.
|
||||
*/
|
||||
class CanonicalPlanEncodingTest {
|
||||
|
||||
private final CanonicalNotificationPlanEncoder encoder = new CanonicalNotificationPlanEncoder();
|
||||
|
||||
@Test
|
||||
@DisplayName("a text and a number that print alike are different variables")
|
||||
void aTextAndANumberThatPrintAlikeAreDifferent() {
|
||||
String asText = payload(Map.of("amount", NotificationVariable.text("1")));
|
||||
String asNumber = payload(Map.of("amount", NotificationVariable.number(1)));
|
||||
|
||||
assertThat(asText)
|
||||
.as("toString() made these one variable, and two different requests one request")
|
||||
.isNotEqualTo(asNumber);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a boolean and the text 'true' are different variables")
|
||||
void aBooleanAndItsTextAreDifferent() {
|
||||
assertThat(payload(Map.of("flag", NotificationVariable.bool(true))))
|
||||
.isNotEqualTo(payload(Map.of("flag", NotificationVariable.text("true"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an explicit null and a missing key are different requests")
|
||||
void anExplicitNullIsNotAMissingKey() {
|
||||
assertThat(payload(Map.of("note", NotificationVariable.nullValue())))
|
||||
.isNotEqualTo(payload(Map.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("numbers that differ only in trailing zeros are one variable")
|
||||
void trailingZerosDoNotChangeANumber() {
|
||||
assertThat(payload(Map.of("amount", NotificationVariable.number(new BigDecimal("1.50")))))
|
||||
.as("1.50 and 1.5 are the same amount")
|
||||
.isEqualTo(payload(Map.of("amount", NotificationVariable.number(new BigDecimal("1.5")))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("numbers a double cannot tell apart stay apart")
|
||||
void decimalPrecisionSurvives() {
|
||||
assertThat(payload(Map.of("amount", NotificationVariable.number(new BigDecimal("0.30")))))
|
||||
.isEqualTo(payload(Map.of("amount", NotificationVariable.number(new BigDecimal("0.3")))));
|
||||
assertThat(
|
||||
payload(
|
||||
Map.of(
|
||||
"amount",
|
||||
NotificationVariable.number(new BigDecimal("0.1000000000000000055")))))
|
||||
.as("this and 0.1 are the same double and different decimals")
|
||||
.isNotEqualTo(
|
||||
payload(Map.of("amount", NotificationVariable.number(new BigDecimal("0.1")))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("insertion order does not change the encoding")
|
||||
void insertionOrderDoesNotChangeTheEncoding() {
|
||||
Map<String, NotificationVariable> forward = new LinkedHashMap<>();
|
||||
forward.put("a", NotificationVariable.text("1"));
|
||||
forward.put("b", NotificationVariable.text("2"));
|
||||
Map<String, NotificationVariable> backward = new LinkedHashMap<>();
|
||||
backward.put("b", NotificationVariable.text("2"));
|
||||
backward.put("a", NotificationVariable.text("1"));
|
||||
|
||||
assertThat(payload(forward)).isEqualTo(payload(backward));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("list order does change the encoding")
|
||||
void listOrderChangesTheEncoding() {
|
||||
assertThat(
|
||||
payload(
|
||||
Map.of(
|
||||
"items",
|
||||
NotificationVariable.list(
|
||||
List.of(NotificationVariable.text("a"), NotificationVariable.text("b"))))))
|
||||
.isNotEqualTo(
|
||||
payload(
|
||||
Map.of(
|
||||
"items",
|
||||
NotificationVariable.list(
|
||||
List.of(NotificationVariable.text("b"), NotificationVariable.text("a"))))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a value containing the framing characters cannot imitate another pair")
|
||||
void framingCharactersCannotBeSmuggled() {
|
||||
// Under a delimited encoding, {a: "1,b=2"} and {a: "1", b: "2"} render the same text.
|
||||
String smuggled = payload(Map.of("a", NotificationVariable.text("1,b=2")));
|
||||
Map<String, NotificationVariable> twoKeys = new LinkedHashMap<>();
|
||||
twoKeys.put("a", NotificationVariable.text("1"));
|
||||
twoKeys.put("b", NotificationVariable.text("2"));
|
||||
|
||||
assertThat(smuggled).isNotEqualTo(payload(twoKeys));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a nested collection cannot imitate a flat one")
|
||||
void nestedCollectionsCannotImitateFlatOnes() {
|
||||
Set<String> encodings =
|
||||
Set.of(
|
||||
payload(Map.of("a", NotificationVariable.text("b"))),
|
||||
payload(
|
||||
Map.of(
|
||||
"a", NotificationVariable.object(Map.of("b", NotificationVariable.text(""))))),
|
||||
payload(
|
||||
Map.of("a", NotificationVariable.list(List.of(NotificationVariable.text("b"))))));
|
||||
|
||||
assertThat(encodings).as("three different shapes, three different encodings").hasSize(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("random variable graphs collide only when they are equal")
|
||||
void randomGraphsCollideOnlyWhenEqual() {
|
||||
// A property check rather than three hand-picked cases: the collisions this replaces were all
|
||||
// shapes nobody thought to write a case for.
|
||||
Random random = new Random(20260815L);
|
||||
Map<String, Map<String, NotificationVariable>> byEncoding = new LinkedHashMap<>();
|
||||
List<String> collisions = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < 2000; i++) {
|
||||
Map<String, NotificationVariable> variables = randomVariables(random, 0);
|
||||
String encoded = payload(variables);
|
||||
Map<String, NotificationVariable> seen = byEncoding.putIfAbsent(encoded, variables);
|
||||
if (seen != null && !seen.equals(variables)) {
|
||||
collisions.add(seen + " and " + variables);
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(collisions).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the hashed bytes contain the payload that will be stored")
|
||||
void theHashedBytesContainTheStoredPayload() {
|
||||
EncodedNotificationPlan encoded =
|
||||
encoder.encode(PlatformFakes.plan("idem-1", Map.of("amount", "1000")));
|
||||
|
||||
assertThat(new String(encoded.bytes(), StandardCharsets.UTF_8))
|
||||
.as("the fingerprint used to cover a canonical string the stored payload had never seen")
|
||||
.contains(encoded.variablesPayload());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the encoding version is inside the bytes, not only beside them")
|
||||
void theVersionIsInsideTheBytes() {
|
||||
EncodedNotificationPlan encoded = encoder.encode(PlatformFakes.plan("idem-1", Map.of()));
|
||||
|
||||
assertThat(new String(encoded.bytes(), StandardCharsets.UTF_8))
|
||||
.startsWith("1:v:1:" + CanonicalNotificationPlanEncoder.VERSION);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a plan cannot be changed after it is built")
|
||||
void aPlanCannotBeChangedAfterItIsBuilt() {
|
||||
Map<String, NotificationVariable> mutable = new LinkedHashMap<>();
|
||||
mutable.put("amount", NotificationVariable.text("1000"));
|
||||
NotificationPlan plan = PlatformFakes.planTyped("idem-1", mutable);
|
||||
String before = payload(plan);
|
||||
|
||||
mutable.put("amount", NotificationVariable.text("9999"));
|
||||
|
||||
assertThat(payload(plan))
|
||||
.as("the caller's map is copied, and every value in it is already immutable")
|
||||
.isEqualTo(before);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a graph deeper than the bound is refused when it is built")
|
||||
void aGraphDeeperThanTheBoundIsRefused() {
|
||||
NotificationVariable deep = NotificationVariable.text("leaf");
|
||||
for (int i = 0; i < NotificationVariable.MAX_DEPTH - 1; i++) {
|
||||
deep = NotificationVariable.list(List.of(deep));
|
||||
}
|
||||
NotificationVariable atLimit = deep;
|
||||
|
||||
assertThatThrownBy(() -> NotificationVariable.list(List.of(atLimit)))
|
||||
.as("refused while building, not while walking it during an encode")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an oversized text value is refused")
|
||||
void anOversizedTextValueIsRefused() {
|
||||
String tooLong = "x".repeat(NotificationVariable.MAX_TEXT_BYTES + 1);
|
||||
|
||||
assertThatThrownBy(() -> NotificationVariable.text(tooLong))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a variable does not print its own content")
|
||||
void aVariableDoesNotPrintItsContent() {
|
||||
assertThat(NotificationVariable.text("user@example.com").toString())
|
||||
.as("an exception message and a log line both go through toString()")
|
||||
.doesNotContain("user@example.com");
|
||||
assertThat(NotificationVariable.number(new BigDecimal("4111111111111111")).toString())
|
||||
.doesNotContain("4111");
|
||||
}
|
||||
|
||||
private String payload(Map<String, NotificationVariable> variables) {
|
||||
return encoder.encode(PlatformFakes.planTyped("idem-1", variables)).variablesPayload();
|
||||
}
|
||||
|
||||
private String payload(NotificationPlan plan) {
|
||||
return encoder.encode(plan).variablesPayload();
|
||||
}
|
||||
|
||||
private static Map<String, NotificationVariable> randomVariables(Random random, int depth) {
|
||||
Map<String, NotificationVariable> variables = new LinkedHashMap<>();
|
||||
int entries = random.nextInt(3);
|
||||
for (int i = 0; i < entries; i++) {
|
||||
variables.put(randomKey(random), randomValue(random, depth));
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
private static String randomKey(Random random) {
|
||||
// Keys drawn from a set that includes the framing characters on purpose.
|
||||
String[] keys = {"a", "b", "a:b", "1:a", "", ":", "a\nb"};
|
||||
return keys[random.nextInt(keys.length)];
|
||||
}
|
||||
|
||||
private static NotificationVariable randomValue(Random random, int depth) {
|
||||
int shape = random.nextInt(depth >= 3 ? 4 : 6);
|
||||
return switch (shape) {
|
||||
case 0 -> NotificationVariable.text(randomKey(random));
|
||||
case 1 -> NotificationVariable.number(new BigDecimal(random.nextInt(5)));
|
||||
case 2 -> NotificationVariable.bool(random.nextBoolean());
|
||||
case 3 -> NotificationVariable.nullValue();
|
||||
case 4 -> {
|
||||
List<NotificationVariable> values = new ArrayList<>();
|
||||
int size = random.nextInt(3);
|
||||
for (int i = 0; i < size; i++) {
|
||||
values.add(randomValue(random, depth + 1));
|
||||
}
|
||||
yield NotificationVariable.list(values);
|
||||
}
|
||||
default -> NotificationVariable.object(randomVariables(random, depth + 1));
|
||||
};
|
||||
}
|
||||
}
|
||||
+123
-21
@@ -48,11 +48,10 @@ class NotificationSubmissionServiceTest {
|
||||
PlatformFakes.templates(true),
|
||||
PlatformFakes.fingerprints(),
|
||||
new CanonicalNotificationPlanWriter(
|
||||
new PlatformFakes.SequentialIds(),
|
||||
PlatformFakes.routes(Channel.EMAIL, Channel.SMS),
|
||||
PlatformFakes.variablesCodec()),
|
||||
new PlatformFakes.SequentialIds(), PlatformFakes.routes(Channel.EMAIL)),
|
||||
transactions,
|
||||
PlatformFakes.tenant("tenant-a"),
|
||||
PlatformFakes.deduplication(CLOCK),
|
||||
metrics,
|
||||
CLOCK);
|
||||
|
||||
@@ -61,22 +60,34 @@ class NotificationSubmissionServiceTest {
|
||||
var receipt = service.submit(PlatformFakes.plan("idem-1", Map.of("amount", "1000")));
|
||||
|
||||
assertThat(requests.byId).containsKey(receipt.notificationId());
|
||||
assertThat(requests.recipientsOf(receipt.notificationId())).hasSize(1);
|
||||
assertThat(requests.recipientsOf(receipt.notificationId()).get(0).state())
|
||||
assertThat(requests.recipientsOf(new TenantId("tenant-a"), receipt.notificationId()))
|
||||
.hasSize(1);
|
||||
assertThat(
|
||||
requests
|
||||
.recipientsOf(new TenantId("tenant-a"), receipt.notificationId())
|
||||
.get(0)
|
||||
.state())
|
||||
.isEqualTo(RecipientDeliveryState.READY_TO_DISPATCH);
|
||||
assertThat(transactions.boundaries).containsExactly("WRITE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aScheduledRequestIsQueuedPendingRatherThanReadyToDispatch() {
|
||||
var receipt =
|
||||
service.schedule(
|
||||
PlatformFakes.plan("idem-scheduled", Map.of()), CLOCK.instant().plusSeconds(3600));
|
||||
void aScheduledRequestIsQueuedWithAFutureDueTimeRatherThanASecondState() {
|
||||
var scheduledFor = CLOCK.instant().plusSeconds(3600);
|
||||
var receipt = service.schedule(PlatformFakes.plan("idem-scheduled", Map.of()), scheduledFor);
|
||||
|
||||
assertThat(requests.byId.get(receipt.notificationId()).status())
|
||||
.isEqualTo(RequestStatus.SCHEDULED);
|
||||
assertThat(requests.recipientsOf(receipt.notificationId()).get(0).state())
|
||||
.isEqualTo(RecipientDeliveryState.PENDING);
|
||||
var recipient =
|
||||
requests.recipientsOf(new TenantId("tenant-a"), receipt.notificationId()).get(0);
|
||||
assertThat(recipient.state())
|
||||
.as(
|
||||
"PENDING was a second queue state the claim query never read, so every schedule(...)"
|
||||
+ " request sat in the table until somebody noticed")
|
||||
.isEqualTo(RecipientDeliveryState.READY_TO_DISPATCH);
|
||||
assertThat(recipient.nextDispatchAt())
|
||||
.as("the schedule is a due time, which the claim already compares against")
|
||||
.hasValue(scheduledFor);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -114,7 +125,7 @@ class NotificationSubmissionServiceTest {
|
||||
assertThat(second.notificationId()).isEqualTo(first.notificationId());
|
||||
assertThat(requests.byId).hasSize(1);
|
||||
// One request row, not two: convergence is the point, not a second identical notification.
|
||||
assertThat(requests.recipientsOf(first.notificationId())).hasSize(1);
|
||||
assertThat(requests.recipientsOf(new TenantId("tenant-a"), first.notificationId())).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -150,11 +161,10 @@ class NotificationSubmissionServiceTest {
|
||||
PlatformFakes.templates(false),
|
||||
PlatformFakes.fingerprints(),
|
||||
new CanonicalNotificationPlanWriter(
|
||||
new PlatformFakes.SequentialIds(),
|
||||
PlatformFakes.routes(Channel.EMAIL),
|
||||
PlatformFakes.variablesCodec()),
|
||||
new PlatformFakes.SequentialIds(), PlatformFakes.routes(Channel.EMAIL)),
|
||||
transactions,
|
||||
PlatformFakes.tenant("tenant-a"),
|
||||
PlatformFakes.deduplication(CLOCK),
|
||||
metrics,
|
||||
CLOCK);
|
||||
|
||||
@@ -169,7 +179,7 @@ class NotificationSubmissionServiceTest {
|
||||
@Test
|
||||
void cancellationReportsExternalUncertaintyRatherThanClaimingTheMessageWasStopped() {
|
||||
var receipt = service.submit(PlatformFakes.plan("idem-6", Map.of()));
|
||||
var job = requests.recipientsOf(receipt.notificationId()).get(0);
|
||||
var job = requests.recipientsOf(new TenantId("tenant-a"), receipt.notificationId()).get(0);
|
||||
requests.replace(withAmbiguousAttempt(job));
|
||||
|
||||
var result =
|
||||
@@ -204,18 +214,18 @@ class NotificationSubmissionServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollUpTreatsAMixedTerminalOutcomeAsPartiallyCompleted() {
|
||||
void theStatusPolicyTreatsAMixedTerminalOutcomeAsPartiallyCompleted() {
|
||||
assertThat(
|
||||
NotificationSubmissionService.rollUp(
|
||||
NotificationRequestStatusPolicy.rollUp(
|
||||
List.of(RecipientDeliveryState.COMPLETED, RecipientDeliveryState.FAILED)))
|
||||
.isEqualTo(RequestStatus.PARTIALLY_COMPLETED);
|
||||
assertThat(
|
||||
NotificationSubmissionService.rollUp(
|
||||
NotificationRequestStatusPolicy.rollUp(
|
||||
List.of(RecipientDeliveryState.COMPLETED, RecipientDeliveryState.RETRY_WAITING)))
|
||||
.isEqualTo(RequestStatus.PROCESSING);
|
||||
assertThat(NotificationSubmissionService.rollUp(List.of(RecipientDeliveryState.CANCELED)))
|
||||
assertThat(NotificationRequestStatusPolicy.rollUp(List.of(RecipientDeliveryState.CANCELED)))
|
||||
.isEqualTo(RequestStatus.CANCELED);
|
||||
assertThat(NotificationSubmissionService.rollUp(List.of())).isEqualTo(RequestStatus.CREATED);
|
||||
assertThat(NotificationRequestStatusPolicy.rollUp(List.of())).isEqualTo(RequestStatus.CREATED);
|
||||
}
|
||||
|
||||
private static RecipientDeliveryRecord withAmbiguousAttempt(RecipientDeliveryRecord job) {
|
||||
@@ -235,6 +245,7 @@ class NotificationSubmissionServiceTest {
|
||||
true,
|
||||
job.duplicateRisk(),
|
||||
job.nextDispatchAt(),
|
||||
job.expiresAt(),
|
||||
job.leaseOwner(),
|
||||
job.leaseUntil(),
|
||||
job.attemptCount(),
|
||||
@@ -242,4 +253,95 @@ class NotificationSubmissionServiceTest {
|
||||
job.createdAt(),
|
||||
job.updatedAt());
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
@org.junit.jupiter.api.DisplayName("a duplicate inside the window is dropped, and says so")
|
||||
void aDuplicateInsideTheWindowIsDropped() {
|
||||
var spec =
|
||||
new dev.caskeleton.application.notification.platform.api.DeduplicationSpec(
|
||||
"order-1",
|
||||
java.time.Duration.ofMinutes(10),
|
||||
dev.caskeleton.application.notification.platform.api.DeduplicationAction.DROP);
|
||||
|
||||
var first = service.submit(planWithDedup("idem-dedup-1", spec));
|
||||
var second = service.submit(planWithDedup("idem-dedup-2", spec));
|
||||
|
||||
assertThat(first.willDeliver()).isTrue();
|
||||
assertThat(second.willDeliver())
|
||||
.as("DeduplicationService and its store were beans nothing called, so every duplicate went")
|
||||
.isFalse();
|
||||
assertThat(second.acceptance())
|
||||
.isEqualTo(
|
||||
dev.caskeleton.application.notification.platform.api.NotificationAcceptance
|
||||
.DROPPED_AS_DUPLICATE);
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
@org.junit.jupiter.api.DisplayName("RETURN_EXISTING converges on the earlier notification")
|
||||
void returnExistingConvergesOnTheEarlierNotification() {
|
||||
var spec =
|
||||
new dev.caskeleton.application.notification.platform.api.DeduplicationSpec(
|
||||
"order-2",
|
||||
java.time.Duration.ofMinutes(10),
|
||||
dev.caskeleton.application.notification.platform.api.DeduplicationAction
|
||||
.RETURN_EXISTING);
|
||||
|
||||
var first = service.submit(planWithDedup("idem-dedup-3", spec));
|
||||
var second = service.submit(planWithDedup("idem-dedup-4", spec));
|
||||
|
||||
assertThat(second.notificationId())
|
||||
.as("the caller is told which notification already covers their request")
|
||||
.isEqualTo(first.notificationId());
|
||||
assertThat(second.acceptance())
|
||||
.isEqualTo(
|
||||
dev.caskeleton.application.notification.platform.api.NotificationAcceptance
|
||||
.CONVERGED_ON_EXISTING);
|
||||
assertThat(second.willDeliver()).isFalse();
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
@org.junit.jupiter.api.DisplayName("a different window admits the next request")
|
||||
void aDifferentWindowAdmitsTheNextRequest() {
|
||||
var spec =
|
||||
new dev.caskeleton.application.notification.platform.api.DeduplicationSpec(
|
||||
"order-3",
|
||||
java.time.Duration.ofMinutes(10),
|
||||
dev.caskeleton.application.notification.platform.api.DeduplicationAction.DROP);
|
||||
var otherKey =
|
||||
new dev.caskeleton.application.notification.platform.api.DeduplicationSpec(
|
||||
"order-4",
|
||||
java.time.Duration.ofMinutes(10),
|
||||
dev.caskeleton.application.notification.platform.api.DeduplicationAction.DROP);
|
||||
|
||||
service.submit(planWithDedup("idem-dedup-5", spec));
|
||||
|
||||
assertThat(service.submit(planWithDedup("idem-dedup-6", otherKey)).willDeliver()).isTrue();
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
@org.junit.jupiter.api.DisplayName("a multi-recipient plan with deduplication is refused")
|
||||
void aMultiRecipientDedupPlanIsRefused() {
|
||||
var spec =
|
||||
new dev.caskeleton.application.notification.platform.api.DeduplicationSpec(
|
||||
"order-5",
|
||||
java.time.Duration.ofMinutes(10),
|
||||
dev.caskeleton.application.notification.platform.api.DeduplicationAction.DROP);
|
||||
|
||||
assertThatThrownBy(() -> service.submit(PlatformFakes.multiRecipientPlanWithDedup(spec)))
|
||||
.as("the window is keyed on one recipient identity; several recipients have no single one")
|
||||
.isInstanceOf(RuntimeException.class);
|
||||
}
|
||||
|
||||
private static dev.caskeleton.application.notification.platform.api.NotificationPlan
|
||||
planWithDedup(
|
||||
String idempotencyKey,
|
||||
dev.caskeleton.application.notification.platform.api.DeduplicationSpec spec) {
|
||||
return PlatformFakes.plan(
|
||||
idempotencyKey,
|
||||
Map.of(),
|
||||
new dev.caskeleton.application.notification.platform.api.routing.ExplicitChannel(
|
||||
Channel.EMAIL),
|
||||
java.util.Optional.empty(),
|
||||
java.util.Optional.of(spec));
|
||||
}
|
||||
}
|
||||
|
||||
+219
-10
@@ -10,6 +10,7 @@ import dev.caskeleton.application.notification.platform.api.NotificationPlan;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.api.RecipientDeliveryId;
|
||||
import dev.caskeleton.application.notification.platform.api.RecipientSpec;
|
||||
import dev.caskeleton.application.notification.platform.api.RequestStatus;
|
||||
import dev.caskeleton.application.notification.platform.api.TemplateSelection;
|
||||
import dev.caskeleton.application.notification.platform.api.TenantId;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
|
||||
@@ -121,21 +122,36 @@ public final class PlatformFakes {
|
||||
public final Map<String, NotificationId> byIdempotency = new HashMap<>();
|
||||
public final Map<NotificationId, List<RecipientDeliveryRecord>> recipients = new HashMap<>();
|
||||
|
||||
/** Set to make the next insert lose the race, as a unique-index violation would. */
|
||||
/**
|
||||
* Set to make the next insert lose the race, as a concurrent claim would.
|
||||
*
|
||||
* <p>Losing is reported, not thrown. The real store used to raise a unique violation, which
|
||||
* left the PostgreSQL transaction aborted and made the winner unreadable — so a fake that
|
||||
* throws would model the bug rather than the contract.
|
||||
*/
|
||||
private static final char KEY_SEPARATOR = '\u0000';
|
||||
|
||||
public boolean failNextInsertAsDuplicate;
|
||||
|
||||
@Override
|
||||
public NotificationRequestRecord insert(
|
||||
public NotificationRequestInsertOutcome insert(
|
||||
NotificationRequestRecord request, List<RecipientDeliveryRecord> jobs) {
|
||||
String key = request.tenantId().value() + '' + request.idempotencyKey();
|
||||
String key = request.tenantId().value() + KEY_SEPARATOR + request.idempotencyKey();
|
||||
if (failNextInsertAsDuplicate || byIdempotency.containsKey(key)) {
|
||||
failNextInsertAsDuplicate = false;
|
||||
throw new DuplicateIdempotencyKeyException("duplicate idempotency key");
|
||||
NotificationRequestRecord winner = byId.get(byIdempotency.get(key));
|
||||
if (winner == null) {
|
||||
throw new DuplicateIdempotencyKeyException(
|
||||
"idempotency key is claimed but its request cannot be read");
|
||||
}
|
||||
// Deliberately no recipient jobs on this path: the loser's jobs would attach a second set
|
||||
// to the winner's request.
|
||||
return NotificationRequestInsertOutcome.lost(winner);
|
||||
}
|
||||
byId.put(request.id(), request);
|
||||
byIdempotency.put(key, request.id());
|
||||
recipients.put(request.id(), new ArrayList<>(jobs));
|
||||
return request;
|
||||
return NotificationRequestInsertOutcome.won(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -151,13 +167,26 @@ public final class PlatformFakes {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RecipientDeliveryRecord> recipientsOf(NotificationId notificationId) {
|
||||
public List<RecipientDeliveryRecord> recipientsOf(
|
||||
TenantId tenantId, NotificationId notificationId) {
|
||||
// Scoped like the real store: another tenant's id answers with nothing, not with rows.
|
||||
NotificationRequestRecord owner = byId.get(notificationId);
|
||||
if (owner == null || !owner.tenantId().equals(tenantId)) {
|
||||
return List.of();
|
||||
}
|
||||
return List.copyOf(recipients.getOrDefault(notificationId, List.of()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationRequestRecord refreshStatus(NotificationId notificationId) {
|
||||
return byId.get(notificationId);
|
||||
public NotificationRequestRecord updateStatus(
|
||||
TenantId tenantId, NotificationId notificationId, RequestStatus status) {
|
||||
NotificationRequestRecord current = byId.get(notificationId);
|
||||
if (current == null || !current.tenantId().equals(tenantId)) {
|
||||
throw new IllegalStateException("notification request no longer exists");
|
||||
}
|
||||
NotificationRequestRecord updated = current.withStatus(status);
|
||||
byId.put(notificationId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Overwrite a stored job, as the recipient store's save would. */
|
||||
@@ -211,6 +240,7 @@ public final class PlatformFakes {
|
||||
job.ambiguousAttemptExists(),
|
||||
job.duplicateRisk(),
|
||||
nextDispatchAt,
|
||||
job.expiresAt(),
|
||||
job.leaseOwner(),
|
||||
job.leaseUntil(),
|
||||
job.attemptCount(),
|
||||
@@ -428,14 +458,193 @@ public final class PlatformFakes {
|
||||
"a".repeat(64));
|
||||
}
|
||||
|
||||
/**
|
||||
* A deduplication service over an in-memory window store.
|
||||
*
|
||||
* <p>The real store is a JPA one whose uniqueness makes the claim atomic; this keeps the same
|
||||
* shape — first claim wins, later claims in the same window see the winner — so a test that
|
||||
* passes here is testing the protocol.
|
||||
*/
|
||||
public static dev.caskeleton.application.notification.platform.policy.DeduplicationService
|
||||
deduplication(java.time.Clock clock) {
|
||||
java.util.Map<String, dev.caskeleton.application.notification.platform.api.NotificationId>
|
||||
claimed = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
dev.caskeleton.application.notification.platform.policy.DeduplicationStorePort store =
|
||||
(recipient, dedupKey, windowBucket, candidate) -> {
|
||||
String key =
|
||||
recipient.tenantId().value()
|
||||
+ "\u0000"
|
||||
+ recipient.recipientRef()
|
||||
+ "\u0000"
|
||||
+ recipient.category()
|
||||
+ "\u0000"
|
||||
+ dedupKey
|
||||
+ "\u0000"
|
||||
+ windowBucket;
|
||||
var winner = claimed.putIfAbsent(key, candidate);
|
||||
return winner == null
|
||||
? dev.caskeleton.application.notification.platform.policy.DeduplicationResult.first(
|
||||
candidate)
|
||||
: new dev.caskeleton.application.notification.platform.policy.DeduplicationResult(
|
||||
candidate, java.util.Optional.of(winner));
|
||||
};
|
||||
return new dev.caskeleton.application.notification.platform.policy.DeduplicationService(
|
||||
store, clock);
|
||||
}
|
||||
|
||||
/** A two-recipient plan carrying a deduplication spec, which the platform must refuse. */
|
||||
public static NotificationPlan multiRecipientPlanWithDedup(
|
||||
dev.caskeleton.application.notification.platform.api.DeduplicationSpec spec) {
|
||||
NotificationPlan single =
|
||||
plan(
|
||||
"idem-multi",
|
||||
Map.of(),
|
||||
new ExplicitChannel(Channel.EMAIL),
|
||||
Optional.empty(),
|
||||
Optional.of(spec));
|
||||
RecipientSpec other =
|
||||
new RecipientSpec(
|
||||
"user-2",
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.of(
|
||||
new ContactPointSelector(
|
||||
Channel.EMAIL,
|
||||
new ContactPointId(UUID.fromString("00000000-0000-7000-8000-000000000003")))),
|
||||
Optional.empty());
|
||||
return new NotificationPlan(
|
||||
single.tenantId(),
|
||||
single.idempotencyKey(),
|
||||
single.category(),
|
||||
single.template(),
|
||||
single.variables(),
|
||||
List.of(single.recipients().get(0), other),
|
||||
single.deliveryStrategy(),
|
||||
single.notBefore(),
|
||||
single.expiresAt(),
|
||||
single.deduplication(),
|
||||
single.collapse(),
|
||||
single.correlationId(),
|
||||
single.boundedMetadata());
|
||||
}
|
||||
|
||||
/** A one-recipient email plan. */
|
||||
public static NotificationPlan plan(String idempotencyKey, Map<String, Object> variables) {
|
||||
return plan(idempotencyKey, variables, new ExplicitChannel(Channel.EMAIL));
|
||||
}
|
||||
|
||||
/** A one-recipient plan whose variables are already typed. */
|
||||
public static NotificationPlan planTyped(
|
||||
String idempotencyKey,
|
||||
Map<String, dev.caskeleton.application.notification.platform.api.NotificationVariable>
|
||||
variables) {
|
||||
return buildTyped(
|
||||
idempotencyKey,
|
||||
variables,
|
||||
new ExplicitChannel(Channel.EMAIL),
|
||||
Optional.empty(),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
/** A one-recipient plan with an explicit strategy and a recipient channel override. */
|
||||
public static NotificationPlan plan(
|
||||
String idempotencyKey,
|
||||
Map<String, Object> variables,
|
||||
DeliveryStrategy strategy,
|
||||
Optional<dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride>
|
||||
channelOverride) {
|
||||
return plan(idempotencyKey, variables, strategy, channelOverride, Optional.empty());
|
||||
}
|
||||
|
||||
/** A one-recipient plan with an explicit strategy, an override and a deduplication spec. */
|
||||
public static NotificationPlan plan(
|
||||
String idempotencyKey,
|
||||
Map<String, Object> variables,
|
||||
DeliveryStrategy strategy,
|
||||
Optional<dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride>
|
||||
channelOverride,
|
||||
Optional<dev.caskeleton.application.notification.platform.api.DeduplicationSpec>
|
||||
deduplication) {
|
||||
return build(idempotencyKey, variables, strategy, channelOverride, deduplication);
|
||||
}
|
||||
|
||||
/** A one-recipient plan with an explicit strategy. */
|
||||
public static NotificationPlan plan(
|
||||
String idempotencyKey, Map<String, Object> variables, DeliveryStrategy strategy) {
|
||||
return build(idempotencyKey, variables, strategy, Optional.empty(), Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a loose test map into the closed variable algebra.
|
||||
*
|
||||
* <p>Test convenience only. Production callers construct {@link NotificationVariable} directly,
|
||||
* which is the point: {@code Object} cannot reach a plan any more.
|
||||
*
|
||||
* @param variables the loose map
|
||||
* @return the typed map
|
||||
*/
|
||||
public static Map<
|
||||
String, dev.caskeleton.application.notification.platform.api.NotificationVariable>
|
||||
typed(Map<String, Object> variables) {
|
||||
Map<String, dev.caskeleton.application.notification.platform.api.NotificationVariable> typed =
|
||||
new java.util.LinkedHashMap<>();
|
||||
variables.forEach((key, value) -> typed.put(key, typedValue(value)));
|
||||
return Map.copyOf(typed);
|
||||
}
|
||||
|
||||
private static dev.caskeleton.application.notification.platform.api.NotificationVariable
|
||||
typedValue(Object value) {
|
||||
if (value == null) {
|
||||
return dev.caskeleton.application.notification.platform.api.NotificationVariable.nullValue();
|
||||
}
|
||||
if (value instanceof String text) {
|
||||
return dev.caskeleton.application.notification.platform.api.NotificationVariable.text(text);
|
||||
}
|
||||
if (value instanceof Boolean flag) {
|
||||
return dev.caskeleton.application.notification.platform.api.NotificationVariable.bool(flag);
|
||||
}
|
||||
if (value instanceof java.math.BigDecimal number) {
|
||||
return dev.caskeleton.application.notification.platform.api.NotificationVariable.number(
|
||||
number);
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return dev.caskeleton.application.notification.platform.api.NotificationVariable.number(
|
||||
new java.math.BigDecimal(number.toString()));
|
||||
}
|
||||
if (value instanceof Map<?, ?> nested) {
|
||||
Map<String, dev.caskeleton.application.notification.platform.api.NotificationVariable> typed =
|
||||
new java.util.LinkedHashMap<>();
|
||||
nested.forEach((key, nestedValue) -> typed.put(String.valueOf(key), typedValue(nestedValue)));
|
||||
return dev.caskeleton.application.notification.platform.api.NotificationVariable.object(
|
||||
typed);
|
||||
}
|
||||
if (value instanceof java.util.List<?> nested) {
|
||||
return dev.caskeleton.application.notification.platform.api.NotificationVariable.list(
|
||||
nested.stream().map(PlatformFakes::typedValue).toList());
|
||||
}
|
||||
throw new IllegalArgumentException("no variable shape for " + value.getClass());
|
||||
}
|
||||
|
||||
private static NotificationPlan build(
|
||||
String idempotencyKey,
|
||||
Map<String, Object> variables,
|
||||
DeliveryStrategy strategy,
|
||||
Optional<dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride>
|
||||
channelOverride,
|
||||
Optional<dev.caskeleton.application.notification.platform.api.DeduplicationSpec>
|
||||
deduplication) {
|
||||
return buildTyped(idempotencyKey, typed(variables), strategy, channelOverride, deduplication);
|
||||
}
|
||||
|
||||
private static NotificationPlan buildTyped(
|
||||
String idempotencyKey,
|
||||
Map<String, dev.caskeleton.application.notification.platform.api.NotificationVariable>
|
||||
variables,
|
||||
DeliveryStrategy strategy,
|
||||
Optional<dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride>
|
||||
channelOverride,
|
||||
Optional<dev.caskeleton.application.notification.platform.api.DeduplicationSpec>
|
||||
deduplication) {
|
||||
return new NotificationPlan(
|
||||
new TenantId("tenant-a"),
|
||||
new IdempotencyKey(idempotencyKey),
|
||||
@@ -456,11 +665,11 @@ public final class PlatformFakes {
|
||||
Channel.SMS,
|
||||
new ContactPointId(
|
||||
UUID.fromString("00000000-0000-7000-8000-000000000002")))),
|
||||
Optional.empty())),
|
||||
channelOverride)),
|
||||
strategy,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
deduplication,
|
||||
Optional.empty(),
|
||||
new CorrelationId("corr-1"),
|
||||
Map.of());
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride;
|
||||
import dev.caskeleton.application.notification.platform.api.ContactPointId;
|
||||
import dev.caskeleton.application.notification.platform.api.ContactPointSelector;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.api.RecipientSpec;
|
||||
import dev.caskeleton.application.notification.platform.api.TenantId;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.OrderedFallback;
|
||||
import dev.caskeleton.application.notification.platform.policy.RouteCandidate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Whether a recipient's channel preference decides anything.
|
||||
*
|
||||
* <p>The planner read {@code blockedChannels} and ignored {@code preferredOrder} entirely, so a
|
||||
* recipient who asked to be reached on SMS before email was sent an email. The preference was
|
||||
* accepted through the public API, stored, and then never consulted — which is worse than not
|
||||
* offering it, because the caller believes it took effect.
|
||||
*/
|
||||
class PreferredOrderRoutingTest {
|
||||
|
||||
private static final TenantId TENANT = new TenantId("tenant-a");
|
||||
|
||||
private final PolicyRoutePlanner planner =
|
||||
new PolicyRoutePlanner(
|
||||
catalog(
|
||||
Map.of(
|
||||
Channel.EMAIL, new ProviderProfileId("email-main"),
|
||||
Channel.SMS, new ProviderProfileId("sms-main"),
|
||||
Channel.PUSH, new ProviderProfileId("push-main"))));
|
||||
|
||||
@Test
|
||||
@DisplayName("a preferred channel is tried before the strategy's own order")
|
||||
void aPreferredChannelIsTriedFirst() {
|
||||
List<RouteCandidate> routes =
|
||||
planner.plan(
|
||||
TENANT,
|
||||
recipient(Optional.of(new ChannelPreferenceOverride(List.of(Channel.SMS), Set.of()))),
|
||||
new OrderedFallback(List.of(Channel.EMAIL, Channel.SMS)));
|
||||
|
||||
assertThat(routes.stream().map(RouteCandidate::channel))
|
||||
.as("preferredOrder was read by nothing at all")
|
||||
.containsExactly(Channel.SMS, Channel.EMAIL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("channels the preference does not mention keep the strategy's order")
|
||||
void unmentionedChannelsKeepTheStrategyOrder() {
|
||||
List<RouteCandidate> routes =
|
||||
planner.plan(
|
||||
TENANT,
|
||||
recipient(Optional.of(new ChannelPreferenceOverride(List.of(Channel.PUSH), Set.of()))),
|
||||
new OrderedFallback(List.of(Channel.EMAIL, Channel.SMS, Channel.PUSH)));
|
||||
|
||||
assertThat(routes.stream().map(RouteCandidate::channel))
|
||||
.as("the fallback the caller designed still holds behind the recipient's wishes")
|
||||
.containsExactly(Channel.PUSH, Channel.EMAIL, Channel.SMS);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("preferring a channel the strategy never offered does not add it")
|
||||
void preferringAnUnofferedChannelDoesNotAddIt() {
|
||||
List<RouteCandidate> routes =
|
||||
planner.plan(
|
||||
TENANT,
|
||||
recipient(Optional.of(new ChannelPreferenceOverride(List.of(Channel.PUSH), Set.of()))),
|
||||
new OrderedFallback(List.of(Channel.EMAIL)));
|
||||
|
||||
assertThat(routes.stream().map(RouteCandidate::channel))
|
||||
.as("a preference is an intersection with what was offered, not a replacement")
|
||||
.containsExactly(Channel.EMAIL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a blocked channel is still routed but not eligible")
|
||||
void aBlockedChannelIsStillRoutedButNotEligible() {
|
||||
List<RouteCandidate> routes =
|
||||
planner.plan(
|
||||
TENANT,
|
||||
recipient(Optional.of(new ChannelPreferenceOverride(List.of(), Set.of(Channel.EMAIL)))),
|
||||
new OrderedFallback(List.of(Channel.EMAIL, Channel.SMS)));
|
||||
|
||||
assertThat(routes).hasSize(2);
|
||||
assertThat(routes.get(0).contactPointActive())
|
||||
.as("a blocked channel is still planned, so an operator can see it was considered")
|
||||
.isFalse();
|
||||
assertThat(routes.get(1).contactPointActive()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no preference leaves the strategy's order untouched")
|
||||
void noPreferenceLeavesTheOrderUntouched() {
|
||||
List<RouteCandidate> routes =
|
||||
planner.plan(
|
||||
TENANT,
|
||||
recipient(Optional.empty()),
|
||||
new OrderedFallback(List.of(Channel.EMAIL, Channel.SMS)));
|
||||
|
||||
assertThat(routes.stream().map(RouteCandidate::channel))
|
||||
.containsExactly(Channel.EMAIL, Channel.SMS);
|
||||
}
|
||||
|
||||
private static RecipientSpec recipient(Optional<ChannelPreferenceOverride> override) {
|
||||
return new RecipientSpec(
|
||||
"user-1",
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.of(
|
||||
new ContactPointSelector(Channel.EMAIL, new ContactPointId(UUID.randomUUID())),
|
||||
new ContactPointSelector(Channel.SMS, new ContactPointId(UUID.randomUUID())),
|
||||
new ContactPointSelector(Channel.PUSH, new ContactPointId(UUID.randomUUID()))),
|
||||
override);
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured catalogue, as a fake.
|
||||
*
|
||||
* @param profilesByChannel which profile serves which channel
|
||||
* @return the catalogue port
|
||||
*/
|
||||
private static ProviderProfileCatalogPort catalog(
|
||||
java.util.Map<
|
||||
dev.caskeleton.application.notification.platform.api.routing.Channel,
|
||||
dev.caskeleton.application.notification.platform.api.ProviderProfileId>
|
||||
profilesByChannel) {
|
||||
return channel -> java.util.Optional.ofNullable(profilesByChannel.get(channel));
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ChannelPreferenceOverride;
|
||||
import dev.caskeleton.application.notification.platform.api.NotificationPlan;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.OrderedFallback;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Which requests the idempotency fingerprint treats as the same request.
|
||||
*
|
||||
* <p>It treated several genuinely different ones as identical. The strategy contributed only its
|
||||
* class name and primary channel, so {@code [EMAIL, SMS]} and {@code [EMAIL, PUSH]} hashed the
|
||||
* same. The recipient's channel override contributed nothing at all. The deduplication action — the
|
||||
* difference between dropping a duplicate and collapsing it — was left out while the key and window
|
||||
* were included.
|
||||
*
|
||||
* <p>The encoding was also ambiguous: {@code key=value} pairs joined by commas, over values
|
||||
* rendered with {@code toString()}. A value containing a comma produced the same canonical string
|
||||
* as two different variables would.
|
||||
*/
|
||||
class RequestFingerprintTest {
|
||||
|
||||
private final RequestFingerprint fingerprint = PlatformFakes.fingerprints();
|
||||
|
||||
@Test
|
||||
@DisplayName("a different fallback tail is a different request")
|
||||
void aDifferentFallbackTailIsADifferentRequest() {
|
||||
String emailThenSms = hash(planWithStrategy(List.of(Channel.EMAIL, Channel.SMS)));
|
||||
String emailThenPush = hash(planWithStrategy(List.of(Channel.EMAIL, Channel.PUSH)));
|
||||
|
||||
assertThat(emailThenSms)
|
||||
.as("only the class name and the primary channel used to reach the hash")
|
||||
.isNotEqualTo(emailThenPush);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a different fallback order is a different request")
|
||||
void aDifferentFallbackOrderIsADifferentRequest() {
|
||||
assertThat(hash(planWithStrategy(List.of(Channel.EMAIL, Channel.SMS))))
|
||||
.isNotEqualTo(hash(planWithStrategy(List.of(Channel.SMS, Channel.EMAIL))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a recipient channel override changes the fingerprint")
|
||||
void aChannelOverrideChangesTheFingerprint() {
|
||||
String withoutOverride = hash(planWithOverride(Optional.empty()));
|
||||
String withOverride =
|
||||
hash(
|
||||
planWithOverride(
|
||||
Optional.of(
|
||||
new ChannelPreferenceOverride(List.of(Channel.SMS), Set.of(Channel.EMAIL)))));
|
||||
|
||||
assertThat(withoutOverride)
|
||||
.as("the override decides which channels this recipient may receive on")
|
||||
.isNotEqualTo(withOverride);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a value containing the old separator no longer imitates another request")
|
||||
void aValueContainingTheSeparatorDoesNotCollide() {
|
||||
String withComma = hash(planWithVariables(Map.of("a", "1,b=2")));
|
||||
String twoVariables = hash(planWithVariables(Map.of("a", "1", "b", "2")));
|
||||
|
||||
assertThat(withComma)
|
||||
.as("comma-joined key=value pairs made these two the same canonical string")
|
||||
.isNotEqualTo(twoVariables);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("map insertion order does not change the fingerprint")
|
||||
void mapInsertionOrderDoesNotChangeTheFingerprint() {
|
||||
Map<String, Object> oneOrder = new LinkedHashMap<>();
|
||||
oneOrder.put("a", "1");
|
||||
oneOrder.put("b", "2");
|
||||
Map<String, Object> otherOrder = new LinkedHashMap<>();
|
||||
otherOrder.put("b", "2");
|
||||
otherOrder.put("a", "1");
|
||||
|
||||
assertThat(hash(planWithVariables(oneOrder))).isEqualTo(hash(planWithVariables(otherOrder)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mutating a nested value after construction does not change what was hashed")
|
||||
void mutatingANestedValueAfterConstructionChangesNothing() {
|
||||
Map<String, Object> nested = new LinkedHashMap<>();
|
||||
nested.put("amount", "100");
|
||||
Map<String, Object> variables = new LinkedHashMap<>();
|
||||
variables.put("order", nested);
|
||||
|
||||
NotificationPlan plan = planWithVariables(variables);
|
||||
String before = hash(plan);
|
||||
nested.put("amount", "999");
|
||||
|
||||
assertThat(hash(plan))
|
||||
.as("a shallow copy left every nested collection the caller's own object")
|
||||
.isEqualTo(before);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the same plan fingerprints the same way twice")
|
||||
void theSamePlanFingerprintsTheSameWayTwice() {
|
||||
NotificationPlan plan = planWithStrategy(List.of(Channel.EMAIL, Channel.SMS));
|
||||
|
||||
assertThat(hash(plan)).isEqualTo(hash(plan));
|
||||
}
|
||||
|
||||
private static NotificationPlan planWithStrategy(List<Channel> channels) {
|
||||
return PlatformFakes.plan("idem-1", Map.of(), new OrderedFallback(channels), Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a plan the way production does: encode once, then hash the bytes.
|
||||
*
|
||||
* @param plan the plan
|
||||
* @return the fingerprint
|
||||
*/
|
||||
private String hash(NotificationPlan plan) {
|
||||
return fingerprint.of(new CanonicalNotificationPlanEncoder().encode(plan));
|
||||
}
|
||||
|
||||
private static NotificationPlan planWithVariables(Map<String, Object> variables) {
|
||||
return PlatformFakes.plan(
|
||||
"idem-1", variables, new OrderedFallback(List.of(Channel.EMAIL)), Optional.empty());
|
||||
}
|
||||
|
||||
private static NotificationPlan planWithOverride(Optional<ChannelPreferenceOverride> override) {
|
||||
return PlatformFakes.plan(
|
||||
"idem-1", Map.of(), new OrderedFallback(List.of(Channel.EMAIL)), override);
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package dev.caskeleton.application.notification.platform.dispatch;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.callback.NormalizedEventType;
|
||||
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Whether two different reconciliation answers about one attempt are two different events.
|
||||
*
|
||||
* <p>They were not. The fingerprint was a seed string reinterpreted as a {@code BigInteger},
|
||||
* rendered as hex, and truncated to 64 characters — which is the first 32 bytes of the seed, all of
|
||||
* them inside the attempt's UUID. The event type and native type sat past the cut and could not
|
||||
* change the result, so "accepted" and "delivered" for the same attempt fingerprinted identically
|
||||
* and the ledger's uniqueness constraint dropped whichever arrived second.
|
||||
*/
|
||||
class SyntheticEventFingerprintTest {
|
||||
|
||||
private static final ProviderProfileId PROFILE = new ProviderProfileId("ses-primary");
|
||||
private static final DeliveryAttemptId ATTEMPT =
|
||||
new DeliveryAttemptId(UUID.fromString("0198f1a2-3b4c-7d8e-9f01-234567890abc"));
|
||||
|
||||
private final SyntheticEventFingerprint fingerprints =
|
||||
new SyntheticEventFingerprint(SyntheticEventFingerprintTest::sha256);
|
||||
|
||||
@Test
|
||||
@DisplayName("accepted and delivered for the same attempt are different events")
|
||||
void acceptedAndDeliveredAreDifferentEvents() {
|
||||
String accepted =
|
||||
fingerprints.of(PROFILE, ATTEMPT, event(NormalizedEventType.PROVIDER_ACCEPTED, "Send"));
|
||||
String delivered =
|
||||
fingerprints.of(
|
||||
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery"));
|
||||
|
||||
assertThat(accepted)
|
||||
.as("the later correction used to be discarded as a duplicate of the earlier one")
|
||||
.isNotEqualTo(delivered);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the same answer read twice is the same event")
|
||||
void theSameAnswerReadTwiceIsTheSameEvent() {
|
||||
assertThat(
|
||||
fingerprints.of(
|
||||
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")))
|
||||
.isEqualTo(
|
||||
fingerprints.of(
|
||||
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a different attempt is a different event")
|
||||
void aDifferentAttemptIsADifferentEvent() {
|
||||
DeliveryAttemptId other = new DeliveryAttemptId(UUID.randomUUID());
|
||||
|
||||
assertThat(
|
||||
fingerprints.of(
|
||||
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")))
|
||||
.isNotEqualTo(
|
||||
fingerprints.of(
|
||||
PROFILE, other, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a different provider profile is a different event")
|
||||
void aDifferentProfileIsADifferentEvent() {
|
||||
assertThat(
|
||||
fingerprints.of(
|
||||
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")))
|
||||
.isNotEqualTo(
|
||||
fingerprints.of(
|
||||
new ProviderProfileId("ses-secondary"),
|
||||
ATTEMPT,
|
||||
event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the same native type differing only by where a delimiter falls does not collide")
|
||||
void aDelimiterCannotBeSmuggled() {
|
||||
// "a|b" + "c" and "a" + "b|c" are the same joined string under a delimiter scheme. Length
|
||||
// framing makes the boundary a count rather than a character a value can contain.
|
||||
String first =
|
||||
fingerprints.of(PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "a|b"));
|
||||
String second =
|
||||
fingerprints.of(PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "a"));
|
||||
|
||||
assertThat(first).isNotEqualTo(second);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two spellings of one instant are one event")
|
||||
void twoSpellingsOfOneInstantAreOneEvent() {
|
||||
Instant utc = Instant.parse("2026-08-15T09:00:00Z");
|
||||
Instant offset = OffsetDateTime.parse("2026-08-15T11:00:00+02:00").toInstant();
|
||||
|
||||
assertThat(fingerprints.of(PROFILE, ATTEMPT, eventAt(utc)))
|
||||
.as("Z and +02:00 name the same moment; rendering them as text would make them two")
|
||||
.isEqualTo(fingerprints.of(PROFILE, ATTEMPT, eventAt(offset)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two different instants are two events")
|
||||
void twoDifferentInstantsAreTwoEvents() {
|
||||
assertThat(fingerprints.of(PROFILE, ATTEMPT, eventAt(Instant.parse("2026-08-15T09:00:00Z"))))
|
||||
.isNotEqualTo(
|
||||
fingerprints.of(PROFILE, ATTEMPT, eventAt(Instant.parse("2026-08-15T09:00:01Z"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a native type differing only in Unicode content is a different event")
|
||||
void unicodeContentIsPartOfTheIdentity() {
|
||||
assertThat(
|
||||
fingerprints.of(
|
||||
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")))
|
||||
.isNotEqualTo(
|
||||
fingerprints.of(
|
||||
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivéry")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the result is 64 lowercase hex characters, as the ledger column requires")
|
||||
void theResultIsSixtyFourHexCharacters() {
|
||||
assertThat(
|
||||
fingerprints.of(
|
||||
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery")))
|
||||
.hasSize(64)
|
||||
.matches("[0-9a-f]{64}");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the fingerprint actually depends on the event, not only on the attempt")
|
||||
void theFingerprintDependsOnTheEvent() {
|
||||
// The old implementation's defining property, stated as a test: every event for one attempt
|
||||
// shared a fingerprint. Any implementation with that property fails here.
|
||||
String first =
|
||||
fingerprints.of(PROFILE, ATTEMPT, event(NormalizedEventType.PROVIDER_ACCEPTED, "Send"));
|
||||
String second =
|
||||
fingerprints.of(PROFILE, ATTEMPT, event(NormalizedEventType.UNDELIVERED, "Bounce"));
|
||||
String third =
|
||||
fingerprints.of(
|
||||
PROFILE, ATTEMPT, event(NormalizedEventType.DELIVERY_CONFIRMED, "Delivery"));
|
||||
|
||||
assertThat(java.util.Set.of(first, second, third)).hasSize(3);
|
||||
}
|
||||
|
||||
private static NormalizedProviderEvent event(NormalizedEventType type, String nativeType) {
|
||||
return new NormalizedProviderEvent(
|
||||
type,
|
||||
nativeType,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.of(Instant.parse("2026-08-15T09:00:00Z")),
|
||||
Map.of());
|
||||
}
|
||||
|
||||
private static NormalizedProviderEvent eventAt(Instant occurredAt) {
|
||||
return new NormalizedProviderEvent(
|
||||
NormalizedEventType.DELIVERY_CONFIRMED,
|
||||
"Delivery",
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.of(occurredAt),
|
||||
Map.of());
|
||||
}
|
||||
|
||||
private static byte[] sha256(byte[] input) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256").digest(input);
|
||||
} catch (NoSuchAlgorithmException unavailable) {
|
||||
throw new IllegalStateException("SHA-256 is required by every Java platform", unavailable);
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package dev.caskeleton.application.notification.platform.observation;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* What the platform is allowed to say about itself.
|
||||
*
|
||||
* <p>Two claims were made and neither was enforced. The cardinality guard checked tag
|
||||
* <em>keys</em>, which come from a fixed list in its own source — never the unbounded side; the
|
||||
* values, which are caller-supplied categories, template ids and provider paths, went through
|
||||
* untouched, so one metric could become one time series per request. And {@code
|
||||
* NotificationAuditEvent} carried the sentence "Contact point values never appear here" with
|
||||
* nothing checking it, in the one store that is append-only and widely readable.
|
||||
*/
|
||||
class BoundedObservabilityTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-15T09:00:00Z");
|
||||
|
||||
@Test
|
||||
@DisplayName("ten thousand distinct categories produce a bounded number of series")
|
||||
void tenThousandCategoriesProduceABoundedNumberOfSeries() {
|
||||
CardinalityGuard guard = new CardinalityGuard();
|
||||
Set<String> series = new HashSet<>();
|
||||
|
||||
for (int i = 0; i < 10_000; i++) {
|
||||
series.add(
|
||||
guard.bound(Map.of("notificationCategory", "category-" + i)).get("notificationCategory"));
|
||||
}
|
||||
|
||||
assertThat(series)
|
||||
.as("a caller passing a request id as a category used to create a series per request")
|
||||
.hasSizeLessThanOrEqualTo(65)
|
||||
.contains(CardinalityGuard.OTHER);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ten thousand distinct callback paths produce a bounded number of series")
|
||||
void tenThousandPathsProduceABoundedNumberOfSeries() {
|
||||
CardinalityGuard guard = new CardinalityGuard();
|
||||
Set<String> series = new HashSet<>();
|
||||
|
||||
for (int i = 0; i < 10_000; i++) {
|
||||
series.add(guard.bound(Map.of("templateId", "tpl-" + i)).get("templateId"));
|
||||
}
|
||||
|
||||
assertThat(series).hasSizeLessThanOrEqualTo(65);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the values a deployment really uses keep their own series")
|
||||
void theValuesADeploymentReallyUsesKeepTheirOwnSeries() {
|
||||
CardinalityGuard guard = new CardinalityGuard();
|
||||
|
||||
assertThat(guard.bound(Map.of("channel", "EMAIL"))).containsEntry("channel", "EMAIL");
|
||||
assertThat(guard.bound(Map.of("channel", "SMS"))).containsEntry("channel", "SMS");
|
||||
assertThat(guard.bound(Map.of("attemptBucket", "3"))).containsEntry("attemptBucket", "3");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an address never becomes a metric tag")
|
||||
void anAddressNeverBecomesAMetricTag() {
|
||||
CardinalityGuard guard = new CardinalityGuard();
|
||||
|
||||
assertThat(guard.bound(Map.of("provider", "user@example.com")))
|
||||
.containsEntry("provider", CardinalityGuard.OTHER);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unknown tag key is still refused outright")
|
||||
void anUnknownTagKeyIsStillRefused() {
|
||||
assertThatThrownBy(() -> new CardinalityGuard().bound(Map.of("recipientRef", "r-1")))
|
||||
.isInstanceOf(IllegalMetricTagException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an audit event refuses an address, a phone number, a token and a code")
|
||||
void anAuditEventRefusesContactAndCredentialValues() {
|
||||
for (String leak :
|
||||
new String[] {
|
||||
"user@example.com",
|
||||
"+1 415 555 0142",
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc",
|
||||
"Bearer sk-live-1",
|
||||
"483920",
|
||||
"-----BEGIN PRIVATE KEY-----"
|
||||
}) {
|
||||
assertThatThrownBy(() -> auditWithAttribute("detail", leak))
|
||||
.as("%s", leak)
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the refusal message does not repeat the value it refused")
|
||||
void theRefusalMessageDoesNotRepeatTheValue() {
|
||||
assertThatThrownBy(() -> auditWithAttribute("detail", "user@example.com"))
|
||||
.as("an exception message is itself logged, so naming the value leaks it through the check")
|
||||
.hasMessageNotContaining("user@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an actor, a reason and an operation id are checked too, not only attributes")
|
||||
void everyStringTheEventCarriesIsChecked() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationAuditEvent(
|
||||
"ADMIN_REDRIVE",
|
||||
"operator@example.com",
|
||||
Optional.of("REDRIVE"),
|
||||
Optional.of("op-1"),
|
||||
NOW,
|
||||
Map.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new NotificationAuditEvent(
|
||||
"ADMIN_REDRIVE",
|
||||
"operator-1",
|
||||
Optional.of("otp=483920"),
|
||||
Optional.of("op-1"),
|
||||
NOW,
|
||||
Map.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an ordinary audit event is unaffected")
|
||||
void anOrdinaryAuditEventIsUnaffected() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
new NotificationAuditEvent(
|
||||
"ADMIN_REDRIVE",
|
||||
"operator-1",
|
||||
Optional.of("INCIDENT-4821"),
|
||||
Optional.of("op-1"),
|
||||
NOW,
|
||||
Map.of("provider", "ses", "channel", "EMAIL", "generation", "3")))
|
||||
.as("a reference — an id, a hash, a key id — is exactly what the audit trail should carry")
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
private static NotificationAuditEvent auditWithAttribute(String key, String value) {
|
||||
return new NotificationAuditEvent(
|
||||
"ADMIN_REDRIVE",
|
||||
"operator-1",
|
||||
Optional.of("REDRIVE"),
|
||||
Optional.of("op-1"),
|
||||
NOW,
|
||||
Map.of(key, value));
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package dev.caskeleton.application.notification.platform.provider;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome;
|
||||
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 java.time.Duration;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* What a failure before the wire is allowed to claim.
|
||||
*
|
||||
* <p>The dispatch path caught every {@code RuntimeException} from the gateway and recorded {@code
|
||||
* responseLost()} — body committed, outcome ambiguous. Most of what that path throws happens before
|
||||
* any network call: a disabled runtime, an exhausted rate limiter or concurrency permit, a contact
|
||||
* point that could not be revealed, a payload that failed mapping.
|
||||
*
|
||||
* <p>The cost is asymmetric. An ambiguous attempt is deliberately never retried automatically and
|
||||
* never falls back to another channel, so a rate-limiter rejection — a condition that clears in a
|
||||
* second — permanently blocked the delivery it rejected and sent it into a reconciliation that can
|
||||
* only ever answer "the provider has no record of this".
|
||||
*
|
||||
* <p>The information was already there and thrown away: {@link
|
||||
* NotificationFailureDescriptor#preDispatch} sets {@code ambiguous=false}, and every limiter,
|
||||
* runtime-state and configuration rejection uses it.
|
||||
*/
|
||||
class PreDispatchFailureEvidenceTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("a pre-dispatch descriptor already says nothing was transmitted")
|
||||
void aPreDispatchDescriptorSaysNothingWasTransmitted() {
|
||||
NotificationFailureDescriptor descriptor =
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.PROVIDER_UNAVAILABLE, FailureCategory.CAPACITY_REJECTED);
|
||||
|
||||
assertThat(descriptor.ambiguous())
|
||||
.as("this flag was computed, recorded, and then discarded by the dispatch catch block")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a not-submitted result carries no transmission evidence")
|
||||
void aNotSubmittedResultCarriesNoTransmissionEvidence() {
|
||||
ProviderSubmissionResult result =
|
||||
ProviderSubmissionResult.notSubmitted(
|
||||
ProviderFailure.of(
|
||||
NotificationFailureCode.PROVIDER_UNAVAILABLE,
|
||||
FailureCategory.CAPACITY_REJECTED,
|
||||
true),
|
||||
Duration.ZERO);
|
||||
|
||||
assertThat(result.submissionOutcome()).isEqualTo(SubmissionOutcome.NOT_SUBMITTED);
|
||||
assertThat(result.confirmation())
|
||||
.as("a limiter rejection is a definite non-send, not an unknown")
|
||||
.isNotEqualTo(AttemptConfirmation.AMBIGUOUS);
|
||||
assertThat(result.executionEvidence().requestBodyCommitted().value()).isFalse();
|
||||
assertThat(result.executionEvidence().requestStarted().value()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an ambiguous result is what a lost response looks like, and only that")
|
||||
void anAmbiguousResultIsWhatALostResponseLooksLike() {
|
||||
ProviderSubmissionResult result =
|
||||
ProviderSubmissionResult.ambiguous(
|
||||
ProviderFailure.of(
|
||||
NotificationFailureCode.PROVIDER_RESPONSE_LOST,
|
||||
FailureCategory.AMBIGUOUS_SUBMISSION,
|
||||
false),
|
||||
ProviderExecutionEvidence.responseLost(),
|
||||
Duration.ZERO);
|
||||
|
||||
assertThat(result.confirmation()).isEqualTo(AttemptConfirmation.AMBIGUOUS);
|
||||
assertThat(result.executionEvidence().requestBodyCommitted().value())
|
||||
.as("the body reached the provider; only the answer was lost")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a typed pre-write exception converts to a definite non-send")
|
||||
void aTypedPreWriteExceptionConvertsToADefiniteNonSend() {
|
||||
ProviderCallNotStartedException notStarted =
|
||||
new ProviderCallNotStartedException(
|
||||
NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT,
|
||||
FailureCategory.INVALID_PAYLOAD,
|
||||
false,
|
||||
"the rendered payload exceeds the provider limit");
|
||||
|
||||
ProviderFailure failure = notStarted.toFailure();
|
||||
|
||||
assertThat(failure.code()).isEqualTo(NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT);
|
||||
assertThat(failure.retryable()).isFalse();
|
||||
assertThat(notStarted.getMessage())
|
||||
.as("the message reaches logs, so it carries no recipient or payload content")
|
||||
.doesNotContain("@");
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package dev.caskeleton.application.outbox;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* What the outbox accepts as a payload.
|
||||
*
|
||||
* <p>It accepted anything. The envelope serialiser inserts the payload into the JSON document
|
||||
* verbatim and unescaped — that is documented, and nothing enforced the precondition it documents.
|
||||
* A payload that is not valid JSON produces an envelope no consumer can parse, permanently, because
|
||||
* the row is durable and the relay retries it until the attempt budget is gone. A payload
|
||||
* containing the envelope's own field syntax rewrites the envelope.
|
||||
*/
|
||||
class OutboxPayloadPolicyTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("ordinary JSON values are accepted")
|
||||
void ordinaryJsonValuesAreAccepted() {
|
||||
assertThatCode(
|
||||
() -> {
|
||||
OutboxPayloadPolicy.requireValidPayload("{}");
|
||||
OutboxPayloadPolicy.requireValidPayload("{\"orderId\":\"o-1\",\"amount\":12.5e3}");
|
||||
OutboxPayloadPolicy.requireValidPayload("[1,2,{\"a\":[true,false,null]}]");
|
||||
OutboxPayloadPolicy.requireValidPayload("\"a string\"");
|
||||
OutboxPayloadPolicy.requireValidPayload("-0.5");
|
||||
OutboxPayloadPolicy.requireValidPayload(" {\"padded\": true} ");
|
||||
})
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a payload that is not JSON is refused before the row is written")
|
||||
void aPayloadThatIsNotJsonIsRefused() {
|
||||
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload("not json"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("not valid JSON");
|
||||
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload("{\"a\":}"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload("{\"a\":1,}"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload("[1,2"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload(""))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a payload that would rewrite the envelope's own fields is refused")
|
||||
void aPayloadThatWouldRewriteTheEnvelopeIsRefused() {
|
||||
// Inserted verbatim, this closes the payload value and appends a field of its own.
|
||||
assertThatThrownBy(
|
||||
() -> OutboxPayloadPolicy.requireValidPayload("{\"a\":1},\"eventType\":\"forged\""))
|
||||
.as("the payload is concatenated into the envelope, so trailing content is an injection")
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("trailing content");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two JSON values are not one payload")
|
||||
void twoJsonValuesAreNotOnePayload() {
|
||||
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload("{} {}"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("exactly one JSON value");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a raw control character inside a string is refused")
|
||||
void aRawControlCharacterInsideAStringIsRefused() {
|
||||
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload("{\"a\":\"line\nbreak\"}"))
|
||||
.as("a raw newline is invalid JSON and is also what turns one log line into two")
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("control character");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the size limit is applied in UTF-8 bytes, at the boundary")
|
||||
void theSizeLimitIsAppliedInUtf8Bytes() {
|
||||
// A multi-byte character: a limit counted in char units would accept three times too much.
|
||||
String fill = "가";
|
||||
int perCharacter = fill.getBytes(StandardCharsets.UTF_8).length;
|
||||
int charactersThatFit = (OutboxPayloadPolicy.MAX_PAYLOAD_BYTES - 2) / perCharacter;
|
||||
|
||||
assertThatCode(
|
||||
() ->
|
||||
OutboxPayloadPolicy.requireValidPayload(
|
||||
"\"" + fill.repeat(charactersThatFit) + "\""))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
OutboxPayloadPolicy.requireValidPayload(
|
||||
"\"" + fill.repeat(charactersThatFit + 1) + "\""))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("UTF-8 bytes");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a surrogate pair survives the validator")
|
||||
void aSurrogatePairSurvivesTheValidator() {
|
||||
assertThatCode(() -> OutboxPayloadPolicy.requireValidPayload("{\"emoji\":\"🚀\"}"))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(
|
||||
() -> OutboxPayloadPolicy.requireValidPayload("{\"escaped\":\"\\ud83d\\ude80\"}"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deep nesting is refused rather than overflowing the appending thread's stack")
|
||||
void deepNestingIsRefused() {
|
||||
String deep = "[".repeat(5_000) + "]".repeat(5_000);
|
||||
|
||||
assertThatThrownBy(() -> OutboxPayloadPolicy.requireValidPayload(deep))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("nesting");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the outbox event applies the policy at construction")
|
||||
void theOutboxEventAppliesThePolicy() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new OutboxEvent(
|
||||
"evt-1",
|
||||
"WorkLogReserved",
|
||||
"agg-1",
|
||||
"{\"unterminated\": ",
|
||||
java.time.Instant.parse("2026-08-10T09:00:00Z"),
|
||||
"corr-1",
|
||||
"idem-1",
|
||||
OutboxEventStatus.PENDING,
|
||||
0))
|
||||
.as("after the row commits, the relay retries an unparseable message until it is DEAD")
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package dev.caskeleton.application.outbox;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The semantics a bridge must preserve, and the trap it must not fall into.
|
||||
*
|
||||
* <p>Two outbox models exist. The legacy {@code OutboxEventStatus.FAILED} is <em>retryable</em> and
|
||||
* {@code DEAD} is terminal; the platform's {@code OutboxStatus.FAILED} is a definite rejection and
|
||||
* terminal, while {@code AMBIGUOUS} is the retryable one. Mapping those by name — which is what a
|
||||
* mechanical bridge does — inverts both meanings: a definitely-rejected message is retried forever
|
||||
* and an unknown one is parked where nobody looks.
|
||||
*/
|
||||
class OutboxPublishOutcomeTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("only an ambiguous outcome is retryable")
|
||||
void onlyAmbiguousIsRetryable() {
|
||||
assertThat(OutboxPublishOutcome.AMBIGUOUS.retryable()).isTrue();
|
||||
assertThat(OutboxPublishOutcome.CONFIRMED.retryable()).isFalse();
|
||||
assertThat(OutboxPublishOutcome.REJECTED_BEFORE_SEND.retryable())
|
||||
.as("a definite rejection retried forever is a queue that never drains")
|
||||
.isFalse();
|
||||
assertThat(OutboxPublishOutcome.REJECTED_AFTER_BROKER.retryable()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("only a pre-send rejection guarantees nothing was transmitted")
|
||||
void onlyPreSendRejectionGuaranteesNothingWasTransmitted() {
|
||||
assertThat(OutboxPublishOutcome.REJECTED_BEFORE_SEND.nothingTransmitted()).isTrue();
|
||||
assertThat(OutboxPublishOutcome.REJECTED_AFTER_BROKER.nothingTransmitted())
|
||||
.as("the broker received it and refused it; that is a different investigation")
|
||||
.isFalse();
|
||||
assertThat(OutboxPublishOutcome.AMBIGUOUS.nothingTransmitted())
|
||||
.as("the whole point of AMBIGUOUS is that this cannot be asserted")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the legacy retryable state is not the one the platform calls FAILED")
|
||||
void theLegacyRetryableStateIsNotThePlatformsFailed() {
|
||||
// Written as an assertion because the inversion is the trap: `FAILED` is the retryable state
|
||||
// in the legacy model and the terminal one in the platform's. A bridge that maps by name turns
|
||||
// a definite rejection into an infinite retry and an unknown outcome into a parked row.
|
||||
assertThat(OutboxEventStatus.FAILED)
|
||||
.as("legacy FAILED is retryable — it carries next_attempt_at")
|
||||
.isNotEqualTo(OutboxEventStatus.DEAD);
|
||||
assertThat(OutboxPublishOutcome.valueOf("REJECTED_BEFORE_SEND").retryable())
|
||||
.as("the outcome that corresponds to legacy DEAD, not to legacy FAILED")
|
||||
.isFalse();
|
||||
assertThat(OutboxPublishOutcome.AMBIGUOUS.retryable())
|
||||
.as("the outcome that corresponds to legacy FAILED")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a port that can only return or throw reports CONFIRMED")
|
||||
void aPortThatCanOnlyReturnOrThrowReportsConfirmed() {
|
||||
OutboxMessagePublishPort legacy = event -> {};
|
||||
|
||||
assertThat(legacy.publishForOutcome(null))
|
||||
.as("'returned without throwing' has always meant confirmed for such an adapter")
|
||||
.isEqualTo(OutboxPublishOutcome.CONFIRMED);
|
||||
}
|
||||
}
|
||||
+12
-6
@@ -27,7 +27,9 @@ class OutboxRelayFailureReportTest {
|
||||
"correlationId",
|
||||
"attemptCount",
|
||||
"nextAttemptAt",
|
||||
"cause");
|
||||
// Not "cause": a Throwable component is a Throwable in the operational JSON, and the
|
||||
// allowlist that guards every other field had no say over its message or stack.
|
||||
"causeType");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -43,7 +45,9 @@ class OutboxRelayFailureReportTest {
|
||||
assertThat(report.correlationId()).isEqualTo("corr-1");
|
||||
assertThat(report.attemptCount()).isEqualTo(2);
|
||||
assertThat(report.nextAttemptAt()).isEqualTo(NEXT_ATTEMPT_AT);
|
||||
assertThat(report.cause()).isSameAs(CAUSE);
|
||||
assertThat(report.causeType())
|
||||
.as("the raw Throwable used to travel into the operational JSON log with it")
|
||||
.isEqualTo(CAUSE.getClass().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -54,7 +58,9 @@ class OutboxRelayFailureReportTest {
|
||||
|
||||
assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_DEAD_LETTER);
|
||||
assertThat(report.nextAttemptAt()).isNull();
|
||||
assertThat(report.cause()).isSameAs(CAUSE);
|
||||
assertThat(report.causeType())
|
||||
.as("the raw Throwable used to travel into the operational JSON log with it")
|
||||
.isEqualTo(CAUSE.getClass().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -69,7 +75,7 @@ class OutboxRelayFailureReportTest {
|
||||
"corr-1",
|
||||
1,
|
||||
null,
|
||||
CAUSE))
|
||||
CAUSE.getClass().getName()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("code");
|
||||
}
|
||||
@@ -115,7 +121,7 @@ class OutboxRelayFailureReportTest {
|
||||
"corr-1",
|
||||
1,
|
||||
null,
|
||||
CAUSE))
|
||||
CAUSE.getClass().getName()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("nextAttemptAt");
|
||||
}
|
||||
@@ -132,7 +138,7 @@ class OutboxRelayFailureReportTest {
|
||||
"corr-1",
|
||||
1,
|
||||
NEXT_ATTEMPT_AT,
|
||||
CAUSE))
|
||||
CAUSE.getClass().getName()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("nextAttemptAt");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user