195 lines
7.5 KiB
Markdown
195 lines
7.5 KiB
Markdown
# Outbox Transport-Only Cutover Design
|
|
|
|
## Status
|
|
|
|
Approved implementation slice for MSG-015 transport-only cutover.
|
|
|
|
This design deliberately does **not** activate `POLLING_V2` and does not migrate the publication authority to the v2 delivery tables. The existing `outbox_event` writer/store/claim/status authority remains the only active authority. The change makes that legacy authority capable of carrying a canonical integration event without losing the exact platform envelope.
|
|
|
|
## Goal
|
|
|
|
Support both row generations under one legacy relay authority:
|
|
|
|
```text
|
|
business transaction
|
|
-> legacy NewOutboxEvent -> legacy row
|
|
-> canonical ValidatedIntegrationEvent -> canonical-compatible row
|
|
|
|
one OutboxStorePort claim authority
|
|
-> legacy claimed row -> MessageBroker compatibility path
|
|
-> canonical claimed row -> IntegrationEventPublishPort -> messaging platform
|
|
```
|
|
|
|
A row is published through exactly one branch. There is no dual write and no second relay scheduler.
|
|
|
|
## Application boundaries
|
|
|
|
### Canonical append
|
|
|
|
`OutboxAppendPort` becomes the canonical durable append boundary:
|
|
|
|
```java
|
|
void append(ValidatedIntegrationEvent event);
|
|
```
|
|
|
|
### Legacy append
|
|
|
|
Raw R0 payload append moves to an explicitly named compatibility port:
|
|
|
|
```java
|
|
LegacyOutboxAppendPort
|
|
void append(NewOutboxEvent event);
|
|
```
|
|
|
|
Existing sample/durable-operation code that still emits raw `NewOutboxEvent` uses only the legacy port. New canonical code must not call the legacy port.
|
|
|
|
### Claimed row model
|
|
|
|
The relay-facing row is a sealed application model:
|
|
|
|
```text
|
|
ClaimedOutboxEvent
|
|
|- OutboxEvent // legacy R0 claim model retained for compatibility
|
|
`- CanonicalClaimedOutboxEvent // reconstructs one ValidatedIntegrationEvent
|
|
```
|
|
|
|
`OutboxStorePort.claimBatch` returns `List<ClaimedOutboxEvent>`.
|
|
|
|
Common relay state is exposed by the sealed interface: event id, event type, aggregate id, occurred-at, status and attempt count. The canonical subtype also exposes the exact `ValidatedIntegrationEvent`.
|
|
|
|
A persisted row with a **partial** canonical metadata set is corrupt and fails closed during mapping. It is never downgraded to the legacy path.
|
|
|
|
## Storage compatibility projection
|
|
|
|
The existing PostgreSQL `outbox_event` remains authoritative. Add a forward migration after current legacy V12 that:
|
|
|
|
- widens `event_id` to `varchar(96)`;
|
|
- widens `correlation_id` to `varchar(128)`;
|
|
- adds nullable canonical columns to preserve existing rows;
|
|
- adds an all-or-none canonical-shape check;
|
|
- stores exact canonical envelope bytes in `bytea`;
|
|
- keeps the legacy required columns for the rollback window.
|
|
|
|
Canonical required columns:
|
|
|
|
```text
|
|
contract_id
|
|
envelope_version
|
|
payload_version
|
|
logical_destination
|
|
tenant_scope
|
|
aggregate_type
|
|
aggregate_sequence
|
|
event_index
|
|
partition_key
|
|
envelope_bytes
|
|
content_type
|
|
schema_set_hash
|
|
envelope_sha256
|
|
envelope_schema_hash
|
|
payload_schema_hash
|
|
contract_catalog_revision
|
|
destination_binding_revision
|
|
```
|
|
|
|
`causation_id` is optional by the application contract.
|
|
|
|
Existing legacy columns remain populated for canonical rows with this compatibility projection:
|
|
|
|
```text
|
|
event_id = canonical event id
|
|
aggregate_id = canonical aggregate id
|
|
event_type = contract id
|
|
payload = exact envelope bytes decoded as strict UTF-8
|
|
occurred_at = canonical occurred-at
|
|
status = PENDING
|
|
attempt_count = 0
|
|
next_attempt_at = occurred-at
|
|
correlation_id = canonical correlation id
|
|
idempotency_key = event id
|
|
```
|
|
|
|
The canonical encoder currently emits a UTF-8 JSON envelope. The append adapter verifies strict UTF-8 round-trip before storing the compatibility text. Invalid UTF-8 fails the business transaction; replacement characters are forbidden.
|
|
|
|
`partitionKeyBytes` is not stored separately because the canonical model already requires it to be exactly the US-ASCII bytes of `partitionKeyText`. The claimed model reconstructs those bytes from the stored canonical text.
|
|
|
|
## Persistence adapters
|
|
|
|
`OutboxStoreAdapter` remains the legacy claim/status store and implements `LegacyOutboxAppendPort`, not `OutboxAppendPort`.
|
|
|
|
A separate `CanonicalOutboxAppendAdapter` implements `OutboxAppendPort`. It participates in the caller's existing write transaction exactly like the legacy adapter and never opens a local transaction.
|
|
|
|
Both write the same `outbox_event` table; they are alternative semantic inputs, not dual writers for one business fact.
|
|
|
|
## Activation
|
|
|
|
Introduce:
|
|
|
|
```text
|
|
ca-skeleton.outbox.canonical-transport-enabled=false
|
|
```
|
|
|
|
Default remains false.
|
|
|
|
When false:
|
|
- existing legacy append/relay behavior is unchanged;
|
|
- canonical append bean is not exposed;
|
|
- canonical relay routing is not considered an active deployment capability.
|
|
|
|
When true:
|
|
- canonical append bean is exposed;
|
|
- startup requires an `IntegrationEventPublishPort`;
|
|
- the relay publisher can route canonical claimed rows to that port;
|
|
- legacy rows continue through `MessageBroker`;
|
|
- while mixed legacy rows may still exist, a relay-enabled deployment still requires the legacy broker. Canonical transport is an additional route, not permission to strand legacy backlog.
|
|
|
|
The gate is a compatibility/cutover gate only. It does not change DB publication authority and does not activate `POLLING_V2`.
|
|
|
|
## Publish routing
|
|
|
|
`OutboxMessagePublishPort` remains the one relay publish port and accepts `ClaimedOutboxEvent`.
|
|
|
|
Implementation behavior:
|
|
- `OutboxEvent` -> existing `OutboxEnvelopeJson` + `MessageBroker`.
|
|
- `CanonicalClaimedOutboxEvent` -> exact stored `ValidatedIntegrationEvent` -> `IntegrationEventPublishPort`.
|
|
|
|
The canonical branch blocks on the returned `CompletionStage` only at this legacy compatibility boundary, because the current legacy relay port is synchronous. The platform result is mapped unchanged into `OutboxPublishOutcome`.
|
|
|
|
The bridge does not re-encode canonical bytes.
|
|
|
|
If canonical transport is disabled or the canonical publisher is absent, canonical publication fails closed before broker/platform transmission. Startup validation prevents the normal configured case from reaching that state.
|
|
|
|
## Outcome policy
|
|
|
|
The existing legacy relay state machine remains authoritative in this slice:
|
|
- CONFIRMED -> mark PUBLISHED.
|
|
- AMBIGUOUS -> retryable legacy FAILED flow.
|
|
- REJECTED_BEFORE_SEND / REJECTED_AFTER_BROKER -> existing definite-refusal DEAD behavior.
|
|
|
|
This is intentionally the existing compatibility semantics. The richer v2 per-attempt state machine is a later storage-authority cutover.
|
|
|
|
## Non-goals
|
|
|
|
This slice does not:
|
|
- switch `OutboxPublicationAuthority` to `POLLING_V2`;
|
|
- mutate/reconcile `outbox_event_log_v2` or `outbox_delivery_v2`;
|
|
- implement CDC;
|
|
- remove `MessageBroker`, `KafkaSender`, `NewOutboxEvent`, `OutboxEvent`, or the legacy scheduler;
|
|
- migrate old rows into canonical rows;
|
|
- invent tenant, trace, schema or routing metadata for old rows.
|
|
|
|
## Verification
|
|
|
|
Required:
|
|
1. application port split compiles and old raw producers use `LegacyOutboxAppendPort`;
|
|
2. migration integration proves additive columns, exact BYTEA, constraints and legacy compatibility;
|
|
3. canonical append adapter round-trips every canonical field and exact bytes;
|
|
4. partial canonical row mapping fails closed;
|
|
5. legacy row mapping remains unchanged;
|
|
6. relay unit test proves canonical row invokes only `IntegrationEventPublishPort`;
|
|
7. legacy row invokes only `MessageBroker`;
|
|
8. canonical bytes reaching `PlatformIntegrationEventPublishAdapter` are byte-identical;
|
|
9. startup rejects canonical transport enabled without `IntegrationEventPublishPort`;
|
|
10. default-off composition preserves current behavior;
|
|
11. architecture/dependency checks and `git diff --check` pass.
|