Inbox/outbox PostgreSQL
View MarkdownStatus: Stable Package:
dev.vertique.inboxoutbox.postgresqlArtifact:vertique-inbox-outbox-postgresqlDepends on:dev.vertique:vertique-inbox-outbox-core,dev.vertique:vertique-db-postgresql(which bringsdev.vertique:vertique-db-coreforDbSchemasandMigrationDefinition),dev.vertique:vertique-core,dev.vertique:vertique-context,dev.vertique:vertique-deploy,dev.vertique:vertique-logging,dev.vertique:vertique-services,dev.vertique:vertique-job-cron(the maintenance cron jobs).dev.vertique:vertique-db-flywayis test-scope here; the application supplies the migration runner. API reference: javadoc.io
PostgreSQL persistence and relay engine for Transactional Messaging. Provides DefaultInboxService and DefaultOutboxService (the only implementations of the core write APIs), PgInboxOutboxRepository (claim/lease queries, stale recovery), OutboxRelay (relay verticle with POLLING and LISTEN_NOTIFY strategies), and the Flyway migration that creates the inbox and outbox tables. InboxOutboxMigrationsModule declares those migrations as the inbox-outbox migration instance for named Flyway instances, and the optional db.schemas.inbox-outbox setting places both tables in one schema for the migrations and the repository.
Key Classes
Section titled “Key Classes”PgInboxOutboxRepository
Section titled “PgInboxOutboxRepository”PostgreSQL repository that owns the inbox and outbox SQL: inserts, claim queries, state transitions, lease recovery and retention cleanup. The table DDL and the LISTEN/NOTIFY trigger belong to the packaged migration (see Database Schema), and the LISTEN connection belongs to OutboxRelay. Extends PgSqlRepository.
Constructors:
@Injectpublic PgInboxOutboxRepository(Pool pool, PgDbExceptionMapper exceptionMapper)
public PgInboxOutboxRepository(Pool pool, PgDbExceptionMapper exceptionMapper, DbSchemas schemas)The two-argument constructor leaves every table unqualified, so inbox and outbox resolve through the connection’s default schema. The three-argument constructor qualifies every inbox and outbox table reference, in every statement, with the schema that schemas maps to inbox-outbox; with no inbox-outbox entry its SQL is the same as the two-argument constructor’s. Statements are built once at construction. The mapping is validated then: a schema that is not a single valid PostgreSQL identifier (or is reserved by PostgreSQL) throws IllegalArgumentException before any query runs (see PgTableNames in dev.vertique:vertique-db-postgresql). Column qualifiers inside the SQL, such as outbox.id, name the table within the statement and are never schema-qualified. SQL in this document is shown with unqualified table names.
Mapped or not, tryInsert and insert run on the SqlClient the caller passes, so the inbox or outbox row commits or rolls back with the application’s own writes.
Inbox operations:
tryInsert(String messageId, String source, SqlClient tx)— inserts the inbox row in the caller’s transaction; returnstruefor new messages,falsewhen the(messageId, source)pair was already recordedcleanup(int retentionDays, int batchSize)— batch-deletes inbox rows older than the retention threshold
Outbox operations. Every mark* method carries a claimed_by owner guard and returns
Future<Boolean>: false means this node no longer owns the row, so a node whose lease was reclaimed
cannot complete a row another node now owns.
insert(OutboxEntry entry, OutboxMetadata metadata, UUID carrierId, SqlClient tx)— inserts the outbox row in the caller’s transaction; returns the assigned idclaimBatch(int batchSize, String claimedBy, RelayCapabilities capabilities)— short transaction: selects eligiblePENDINGrows withFOR UPDATE SKIP LOCKEDand marks themPROCESSINGmarkPublished(long entryId, String claimedBy)— transitions an owned row toPUBLISHEDmarkRetry(long entryId, String claimedBy, int newAttempt, Instant availableAt, String lastError, String errorType)— transitions an owned row back toPENDINGwith the incremented attempt and backoffmarkDeadLetter(long entryId, String claimedBy, String lastError, String errorType)— transitions an owned row toDEAD_LETTERmarkUnresolvable(long entryId, String claimedBy, Duration delay)— returns an owned row toPENDINGafter the delay without incrementing attempt (theunresolvableoutcome)reclaimStale(Duration leaseTimeout)— returns stalePROCESSINGrows toPENDINGwithout incrementing attempt; deliberately unguarded, because its job is to take rows from nodes that are gonecleanupPublished(int retentionDays, int batchSize)— batch-deletesPUBLISHEDrows past retentioncleanupDeadLetter(int retentionDays, int batchSize)— batch-deletesDEAD_LETTERrows past retention
Claim eligibility filter (applied in SQL):
state = 'PENDING'available_at <= NOW()scheduled_at IS NULL OR scheduled_at <= NOW()- Dynamic destination-eligibility disjunction built from
RelayCapabilities.byType()— see below.
Dynamic destination-eligibility disjunction: claimBatch builds the capability filter at call time from RelayCapabilities.byType() (a Map<DestinationType, ClaimScope>). Each registered scope contributes one SQL predicate:
| Scope | SQL predicate contributed |
|---|---|
ClaimScope.All |
(o.destination_type = $n) |
ClaimScope.Destinations (non-empty snapshot) |
(o.destination_type = $n AND o.destination = ANY($m)) |
ClaimScope.Destinations (empty snapshot) |
(no predicate — claims nothing for that type) |
| No registered scopes, or all Destinations scopes yielded empty sets | FALSE — zero rows claimed |
All destination-type ids and target values are bound as prepared-statement parameters ($n / ANY($m)) — never concatenated into the SQL string. The fixed prefix binds $1 (batchSize) and $2 (claimedBy); dynamic parameters start at $3. The Destinations supplier is evaluated once per claim cycle; validation rules are enforced by buildClaimEligibility (see ClaimScopeException section below).
The read path uses DestinationType.of(...) (lenient factory) when mapping rows from the database — an unrecognised but well-formed destination_type value maps cleanly rather than throwing.
Aggregate head-of-line ordering: When one entry for a given aggregateId is in PROCESSING, later entries for the same aggregate are excluded from claim. Different aggregates may proceed concurrently.
Sharp edge — unregistered destination types: A destination type with no registered handler/scope stays PENDING indefinitely. Because of the per-aggregate head-of-line subquery, an unclaimable-type row at an aggregate HEAD (the lowest id among PENDING/PROCESSING for that aggregate_id) blocks younger claimable siblings of the same aggregate until that type’s handler and claim scope are registered. This is intentional ordering behavior, not a bug.
DefaultInboxService
Section titled “DefaultInboxService”Default InboxService implementation backed by PgInboxOutboxRepository.
pool.withTransaction(tx -> inboxService.processOnce("msg-abc-123", "payments", tx, () -> orderRepository.markPaid(orderId, tx) ));DefaultOutboxService
Section titled “DefaultOutboxService”Default OutboxService implementation backed by PgInboxOutboxRepository. Inserts the outbox row in the caller’s transaction and returns the assigned id.
outboxService.publish(tx, OutboxEntry.builder() .aggregateType("Order") .aggregateId(orderId) .eventType("order.confirmed") .destinationType(DestinationType.KAFKA) .destination("orders") .payload(event) .build()) .onSuccess(id -> log.info("Outbox entry {} recorded", id));OutboxRelay
Section titled “OutboxRelay”Vert.x AbstractVerticle that polls the repository and dispatches claimed rows to registered OutboxDestinationHandler instances. Deployed in SERVICES phase at priority 100.
Capability derivation: OutboxRelay.deriveCapabilities(handlerMap) builds the RelayCapabilities passed to every claimBatch call. It iterates the deduped handler map (produced by buildHandlerMap), calls OutboxDestinationHandler.claimScope() on each handler, and collects the results into a RelayCapabilities keyed by the same DestinationType the dispatch path uses for handler lookup. Fails fast at startup with a NullPointerException (naming the offending destination type) if any handler returns null from claimScope(). TransactionalMessagingPostgresqlModule no longer injects @ServiceTargetIds- or @DelayedJobTargetIds-qualified Set<String> bindings — capability sourcing lives entirely in the handlers.
Relay strategies:
| Strategy | Description |
|---|---|
POLLING |
Fixed-interval timer fires claimBatch() on every cycle |
LISTEN_NOTIFY |
PostgreSQL LISTEN on transactional_outbox_channel triggers immediate claim; periodic polling acts as a safety net for missed notifications or reconnects |
When LISTEN_NOTIFY is configured but the notification channel is unavailable or disconnected, the relay falls back transparently to polling-only mode. No manual intervention required.
Relay cycle (per batch item):
claimBatch()— short transaction selects and marksPROCESSINGrows.- For each claimed row, build
OutboxEnvelopebefore the handler lookup, so a row whose destination type has no handler has an envelope too — applicationheaders(app-only), durable context inmetadata.context, and relay control projected intometadata.delivery.outboxfrom the row columns (no framework keys merged intoheaders). - Call
OutboxDestinationHandler.publish(envelope)— outside the claim transaction. - On success:
markPublished(). - On retryable failure:
markRetry()with incremented attempt and backoff-computedavailableAt. - On permanent failure or exhausted attempts:
markDeadLetter(). - On
unresolvable:markUnresolvable()with short delay; attempt count unchanged. - A failed future from
publish()is treated as retryable. - A handler that throws synchronously from
publish()— an exception, anAssertionErroror aLinkageError— returnsnullinstead of a future, or completes its future withnullis treated as retryable too: it costs that one entry an attempt, the rest of the claimed batch is still delivered, and polling continues. - When the
mark*call of steps 4–7 has settled — succeeded, failed, or thrown — every registeredOutboxPublishObserver(seedev.vertique:vertique-inbox-outbox-core) is notified once with the attempt’s facts and the envelope from step 2. The callback runs on the relay’s context: when themark*call settles on another context or thread, the relay hops back to its own context before it notifies.dispositionRecordedistrueonly when themark*call returnedtrue.
The relay releases the entry’s in-flight slot and schedules the next poll as soon as the mark* call has been made; only the observer notification waits for it to settle. A mark* call or a backoff computation that throws is handled as a failed mark* call: the slot is released, polling continues, and observers are notified with dispositionRecorded false. If building the envelope throws, the slot is released, the row stays claimed until stale-lease recovery, and — with no envelope and no attempt — observers are not notified. Every poll cycle schedules the next one whatever happens in it: a claimBatch that throws, returns null or completes with a null list (handled as an empty batch) leaves the loop running. A handler that throws a StackOverflowError from publish is a failed attempt like any other handler throw; any other VirtualMachineError propagates after the slot has been released and the next poll scheduled.
ClaimScopeException (package-private, extends InboxOutboxConfigurationException): When a ClaimScope.Destinations supplier misbehaves during buildClaimEligibility, the WHOLE claim cycle is aborted with a ClaimScopeException. The returned future is failed with zero rows claimed. Failure conditions:
| Condition | Reason in exception message |
|---|---|
Supplier — or its returned set (size()/iterator()/toArray()) — throws |
"supplier or its result failed" |
Supplier returns null |
"produced a null target set" |
Any element is null or blank |
"contains a null or blank element" |
| Any element exceeds 255 characters | "element exceeds maximum length of 255" |
| More than 10,000 elements | "target set exceeds limit of 10000" |
The exception message names only the destination TYPE and reason category — never the offending target value — and carries no cause, so the relay’s full-throwable failure log cannot leak target ids into observability pipelines. ClaimScopeException is not routed through PgDbExceptionMapper; callers can distinguish a misconfigured handler from a transient database error.
PgInboxOutboxExceptionMapper (stage-2, package-private): DefaultOutboxService.publish and DefaultInboxService.processOnce wrap any DataAccessException that escapes the repository into InboxOutboxPersistenceException (via PgInboxOutboxExceptionMapper.translate), so these public APIs never expose raw DataAccessException to callers. The retryable() signal on InboxOutboxPersistenceException is true for transient and locking failures (deadlock, optimistic/pessimistic lock) and false for all other data-access failures. In processOnce, the wrapping recover is chained on the tryInsert future before the .compose that runs the caller’s work supplier — so a business-logic failure from work propagates unwrapped (FR-IO-004). OutboxMaintenanceException (raised by the maintenance service for cleanup failures) extends InboxOutboxTechnicalException directly and is not processed by PgInboxOutboxExceptionMapper.
Stale lease recovery: A background cron job calls reclaimStale(leaseTimeout) to return rows stuck in PROCESSING beyond leaseTimeoutMs. Multiple relay nodes may share the same database safely.
Per-publish metadata model:
- Application
headersare stored and relayed as-is — application/transport headers only; no framework keys are merged in. - Headers are stored in the
headerscolumn as one JSON object with a text value per key (NULLwhen the entry has no headers). A JSON object has unique keys and JSONB does not keep key order, so headers come back from storage in no guaranteed order. OutboxService.publishrejects headers that cannot be delivered before it inserts anything: anullkey, anullvalue, or a key with the reservedvertique-prefix fails the returned future with anIllegalArgumentExceptionthat names the key (never the value; control characters in the key are replaced by_and the key is cut to 128 characters, here and in the relay’s WARN for a dropped header), so the caller’s transaction can still roll back. Anullheader map means no headers.- When a stored header value is JSON
null(a row written withoutOutboxService.publish), that header is dropped on read — it does not become the text"null"— so the relay logs one WARN with the entry id and the header key, and delivers the rest. - Durable propagation context bound at publish is captured into
metadata.contextand, at the Kafka boundary, projected to reservedvertique-<namespace>headers (e.g.vertique-correlation). - Relay control (message id,
eventType,aggregateType,aggregateId) is exposed at relay time viametadata.delivery.outbox(projected from the row columns) — it is not merged intoheadersand is internal to the relay.aggregateIdis still used as the Kafka message key.
Shutdown: On stop(), the poll timer and LISTEN connection are closed. In-flight publish calls complete independently.
OutboxRelayConfig (owned by dev.vertique:vertique-inbox-outbox-core)
Section titled “OutboxRelayConfig (owned by dev.vertique:vertique-inbox-outbox-core)”This module provides its Dagger binding, deserialized from the inboxOutbox.relay section; the type
itself belongs to the core artifact.
Deserialized from inboxOutbox.relay.
| Field | Default | Description |
|---|---|---|
strategy |
LISTEN_NOTIFY |
POLLING or LISTEN_NOTIFY |
pollingIntervalMs |
1000 |
Milliseconds between poll cycles |
batchSize |
50 |
Maximum rows to claim per cycle |
leaseTimeoutMs |
30000 |
Stale lease recovery threshold in ms |
maxAttempts |
20 |
Currently unread by any runtime path — the per-row attempt limit is fixed at 20 by OutboxEntry’s own default at insert time; setting this key has no effect today |
backoffBaseDelayMs |
1000 |
Base delay for exponential backoff |
backoffMaxDelayMs |
300000 |
Maximum backoff cap in ms |
instances |
1 |
Number of OutboxRelay verticle instances to deploy |
InboxOutboxCleanupConfig (owned by dev.vertique:vertique-inbox-outbox-core)
Section titled “InboxOutboxCleanupConfig (owned by dev.vertique:vertique-inbox-outbox-core)”This module provides its Dagger binding, deserialized from the inboxOutbox.cleanup section; the type
itself belongs to the core artifact.
Deserialized from inboxOutbox.cleanup.
| Field | Default | Description |
|---|---|---|
publishedRetentionDays |
7 |
Days to retain PUBLISHED outbox rows before cleanup |
deadLetterRetentionDays |
30 |
Days to retain DEAD_LETTER outbox rows before cleanup |
inboxRetentionDays |
30 |
Days to retain processed inbox dedup records before cleanup |
cleanupBatchSize |
1000 |
Maximum records deleted per cleanup batch, across each table |
OutboxMaintenanceService, OutboxMaintenanceContract, and OutboxMaintenanceCron
Section titled “OutboxMaintenanceService, OutboxMaintenanceContract, and OutboxMaintenanceCron”The cluster-singleton maintenance path. OutboxMaintenanceCron is the @CronJob entry point (hence
this module’s compile dependency on dev.vertique:vertique-job-cron); it delegates to
OutboxMaintenanceService, which reclaims stale leases and applies the retention cleanups through
PgInboxOutboxRepository. OutboxMaintenanceContract is the service contract the cron target
resolves. Applications install the module and tune the cron through configuration; they do not call
these types.
Database Schema
Section titled “Database Schema”The Flyway migration creates two tables and associated indexes. V1__create_inbox_outbox_tables.sql
is frozen from this Stable release. Every later schema change ships as V2+, never as an edit to
V1.
outbox table — relay rows:
| Column | Type | Description |
|---|---|---|
id |
BIGSERIAL PK |
Auto-assigned outbox entry id |
carrier_id |
UUID NOT NULL UNIQUE |
Framework-generated per-row durable carrier identity, allocated before insert and never derived from application input; a durable identity snapshot is signed against it |
aggregate_type |
VARCHAR(255) |
Logical aggregate type |
aggregate_id |
VARCHAR(255) |
Aggregate instance id |
event_type |
VARCHAR(255) NOT NULL |
Event type |
destination |
VARCHAR(255) NOT NULL |
Stable target id or topic |
destination_type |
VARCHAR(32) NOT NULL |
Open value type — any id matching [A-Za-z0-9_-]{1,32} (built-ins: SERVICE, DELAYED_JOB, KAFKA) |
payload |
JSONB NOT NULL |
Event payload |
headers |
JSONB |
Application and transport headers only, as one JSON object of text values; no framework keys are merged in |
metadata |
JSONB NOT NULL DEFAULT '{}' |
Structured document {"context": …, "delivery": …} — durable propagation context and relay control |
scheduled_at |
TIMESTAMPTZ |
Optional scheduled publish time |
available_at |
TIMESTAMPTZ NOT NULL DEFAULT NOW() |
Earliest publish time |
state |
VARCHAR(32) NOT NULL DEFAULT 'PENDING' |
Relay state |
attempt |
INTEGER NOT NULL DEFAULT 0 |
Current attempt count |
max_attempts |
INTEGER NOT NULL DEFAULT 20 |
Max attempts |
claimed_at |
TIMESTAMPTZ |
Claim timestamp |
claimed_by |
VARCHAR(255) |
Node that claimed the row |
published_at |
TIMESTAMPTZ |
Publish timestamp |
last_error |
TEXT |
Last error message |
error_type |
VARCHAR(500) |
Last error type |
created_at |
TIMESTAMPTZ NOT NULL DEFAULT NOW() |
Row creation time |
updated_at |
TIMESTAMPTZ NOT NULL DEFAULT NOW() |
Row last-modified time |
Indexes: idx_outbox_pending (partial, state = 'PENDING', on available_at ASC, id ASC), idx_outbox_processing (partial, state = 'PROCESSING', on claimed_at ASC), idx_outbox_aggregate (partial, pending/processing non-null aggregates), idx_outbox_published, idx_outbox_dead_letter.
inbox table — dedup log:
| Column | Type | Description |
|---|---|---|
message_id |
VARCHAR(255) NOT NULL |
Inbound message id; primary key is the composite (message_id, source) |
source |
VARCHAR(255) NOT NULL |
Logical source name; part of the composite primary key |
processed_at |
TIMESTAMPTZ NOT NULL DEFAULT NOW() |
When the message was first seen |
Index: idx_inbox_processed_at (for cleanup queries).
LISTEN/NOTIFY trigger: transactional_outbox_notify fires AFTER INSERT ON outbox and calls pg_notify('transactional_outbox_channel', NEW.id::text).
Placement. The migration scripts are unqualified, so the migration instance’s default schema decides where the tables, indexes, the outbox_id_seq sequence, the notify_transactional_outbox function and the trigger land. In named Flyway mode with a db.schemas.inbox-outbox entry, that schema is the default, and the repository qualifies every table with it, so both agree. In single mode the migration lands where the single-run configuration says (see Flyway Configuration). The transactional_outbox_channel notification channel is database-wide: PostgreSQL channels are not schema-scoped, so every relay listening on the database receives every notification. Treat the channel as a wake-up hint, not a tenant boundary; a relay still claims rows only from its own mapped outbox table.
Flyway Configuration
Section titled “Flyway Configuration”The inbox/outbox migration is declared once, by InboxOutboxMigrationsModule, and can be run two ways.
The declaration is metadata only: it names the inbox-outbox instance and the location
classpath:db/migration/inbox-outbox, has no dependencies and needs no connection pool, so a
migration-only component can include it on its own. TransactionalMessagingPostgresqlModule includes it,
so an application that wires the repository contributes the declaration automatically.
Named instances
Section titled “Named instances”Give the inbox and outbox tables their own schema and history table:
{ "db": { "schemas": { "inbox-outbox": "messaging" } }, "flyway": { "mode": "MIGRATE", "named": true, "instances": { "inbox-outbox": {} } }}"named": true turns on named mode; instances only carries per-instance overrides and may be omitted
("named": true alone runs every wired declaration with the root settings). instances without
"named": true is a configuration error.
- One setting places everything.
db.schemas.inbox-outboxis the instance’s default schema and history schema, andPgInboxOutboxRepositoryqualifies every inbox and outbox table with it, so the migrations and every statement use one schema. The tables, indexes, theoutbox_id_seqsequence, the notify function and its trigger all land there, and the history table isflyway_schema_history_inbox_outboxin that schema. - Run it through the named runner. A migration component installs
DbModule,DbFlywayModuleandInboxOutboxMigrationsModule(plus the configuration and Vert.x modules the application already uses), and callsFlywayMigrationRunner.run(vertx);FlywayMigrationStartupStepruns the same engine at startup. The component needs no connection pool.dev.vertique:vertique-db-flywaydocumentsinstances(),run(Vertx), reports and failures. - Without a
db.schemas.inbox-outboxentry the migration uses the first entry of the instance’s Flywayschemaslist, or the connection’s default schema, and the repository stays unqualified. Configure the migration and runtime connections to the same default schema. - Include
DbModulein the migration component (or bindDbSchemasyourself). A named migration component without either ignoresdb.schemas, and the tables can land in a different schema than a runtime component withDbModulequeries. - The application’s own migrations are a separate instance with a disjoint location such as
classpath:db/migration/app. A broadclasspath:db/migrationlocation overlapsclasspath:db/migration/inbox-outboxand is rejected. - A dedicated DDL user is set with
flyway.userandflyway.password(both, or neither), or per instance withflyway.instances.inbox-outbox.userandflyway.instances.inbox-outbox.password. Without them the migration uses thedbpool credentials.
dev.vertique:vertique-db-flyway documents the full instance configuration, the agreement rules between
db.schemas and Flyway schema lists, and the failure model.
Single Flyway configuration
Section titled “Single Flyway configuration”Without flyway.named, the default classpath:db/migration location already includes the
bundled db/migration/inbox-outbox scripts, so no extra location is needed. Every bundled adapter ships its own V1 script, so one history can hold only one of them (a second would fail with a duplicate version); to run several adapters, set "named": true. Wired migration declarations are ignored in a single run, so adding another adapter module never switches the mode. The single-history example:
{ "flyway": { "mode": "MIGRATE", "user": "ddl_user", "password": "ddl_secret" }}If you override locations, list the inbox/outbox directory next to an application directory that does
not contain it, for example ["classpath:db/migration/inbox-outbox", "classpath:db/app"]. Do not list
both classpath:db/migration and classpath:db/migration/inbox-outbox: the first already covers the
second.
In this mode the migration lands where the single-run configuration says, and nothing checks it against
db.schemas. If db.schemas.inbox-outbox is also set, the repository queries that schema, so make the
flyway.schemas list (first entry) or the connection default match it, or the repository will
look in a schema the migration never filled.
Runtime placement
Section titled “Runtime placement”- The repository reads placement from
DbSchemas. A runtime component that installsDbModulegets it fromdb.schemas, with no Flyway module or Flyway configuration. A component that installs neitherDbModulenor its ownDbSchemasbinding keeps unqualified SQL. - Check the keys yourself.
db.schemaskeys are open and are not validated, so a misspelled key (inbox_outboxorinboxoutboxforinbox-outbox) leaves the placement unmapped and the SQL unqualified, with no error. CompareDbSchemas.mappings()with the instance names before deploying. - The runtime database role needs
USAGEon the mapped schema, besides the usual privileges on the tables. - An invalid mapping fails at construction. A mapped schema that is not a single valid PostgreSQL
identifier throws
IllegalArgumentExceptionwhen the repository is created, not on the first query. - The notification channel is database-wide.
transactional_outbox_channelis shared by every schema in the database, so it is not a tenant boundary (see Database Schema). - Your own tables. An application can inject
DbSchemasand place its own tables with the same mapping mechanism; seedev.vertique:vertique-db-core(DbSchemas) andPgTableNamesindev.vertique:vertique-db-postgresql. - One source of
DbSchemas. A component that bindsDbSchemasitself cannot also installDbModule: Dagger reports a duplicate binding at compile time, with no silent precedence.
Dagger Wiring
Section titled “Dagger Wiring”TransactionalMessagingPostgresqlModule includes TransactionalMessagingModule (core), InboxOutboxMigrationsModule (the inbox-outbox migration declaration) and DbSchemasModule (the optional DbSchemas binding), and provides:
| Binding | Type | Description |
|---|---|---|
InboxService |
Singleton | DefaultInboxService backed by PgInboxOutboxRepository |
OutboxService |
Singleton | DefaultOutboxService backed by PgInboxOutboxRepository |
PgInboxOutboxRepository |
Singleton | PostgreSQL repository, built from Optional<DbSchemas>: placed in the schema mapped to inbox-outbox when the component supplies DbSchemas (a component that installs DbModule gets it from db.schemas.inbox-outbox), unqualified when it does not. The InboxRepository and OutboxRepository bindings, both services, the maintenance cron and every relay instance resolve to this one instance, so they share one node identity (each relay instance appends a counter suffix to it for claimed_by) |
Set<MigrationDefinition> |
@IntoSet |
The inbox-outbox declaration from InboxOutboxMigrationsModule: location classpath:db/migration/inbox-outbox, no dependencies |
OutboxRelayConfig |
Singleton | Relay configuration deserialized from inboxOutbox.relay |
InboxOutboxCleanupConfig |
Singleton | Cleanup configuration deserialized from inboxOutbox.cleanup |
Set<VerticleDeployment> |
@ElementsIntoSet |
OutboxRelay verticle; SERVICES phase, priority 100. Every relay instance is given the contributed Set<OutboxPublishObserver>, sorted once per instance by OrderedExtension order |
@Services Set<Object> |
@IntoSet |
OutboxMaintenanceCron — provides cluster-singleton @CronJob entry points for stale-lease recovery and table cleanup |
Compatibility note. PgInboxOutboxRepository is now provided by an explicit @Provides method. A component that installs this module and also provides PgInboxOutboxRepository itself sees a duplicate binding; remove its own provider. The @Inject two-argument constructor remains for components that do not install this module.
RelayCapabilities are derived at startup by OutboxRelay.deriveCapabilities(handlerMap) from the registered Set<OutboxDestinationHandler>. No @ServiceTargetIds- or @DelayedJobTargetIds-qualified Set<String> bindings are provided by this module — each adapter handler owns its own claim scope.
@Component(modules = { VertxModule.class, DispatchModule.class, DbModule.class, // supplies DbSchemas from db.schemas DbPostgresqlModule.class, DbFlywayModule.class, TransactionalMessagingPostgresqlModule.class, TransactionalMessagingServiceModule.class, // optional: SERVICE adapter TransactionalMessagingKafkaModule.class, // optional: KAFKA adapter AppModule.class})public interface AppComponent { ... }Configuration
Section titled “Configuration”{ "inboxOutbox": { "relay": { "strategy": "LISTEN_NOTIFY", "pollingIntervalMs": 1000, "batchSize": 50, "leaseTimeoutMs": 30000, "backoffBaseDelayMs": 1000, "backoffMaxDelayMs": 300000, "instances": 1 }, "cleanup": { "publishedRetentionDays": 7, "deadLetterRetentionDays": 30, "inboxRetentionDays": 30, "cleanupBatchSize": 1000 } }, "cron": { "jobs": { "outbox-cleanup": { "cron": "0 0 */6 * * *" }, "outbox-stale-lease-recovery": { "cron": "*/30 * * * * *" } } }}The previous cleanupIntervalHours field on InboxOutboxCleanupConfig is removed; the configuration object still accepts it for upgrade compatibility (@JsonIgnoreProperties(ignoreUnknown = true)) but the value is ignored. Maintenance cadence is now controlled by cron.jobs.outbox-cleanup.cron (default 0 0 */6 * * * — every 6h on wall-clock boundaries) and cron.jobs.outbox-stale-lease-recovery.cron (default */30 * * * * * — every 30s). The cron periods match the prior setPeriodic defaults, but first-fire timing is wall-clock-aligned rather than uptime-relative.
InboxOutboxPostgresqlComposeValidator requires CronJobRegistrar, CronScheduler, and CronPersistenceMarker. The marker is bound exclusively by CronPersistenceModule, so installing TransactionalMessagingPostgresqlModule without it — including the case where the in-memory CronModule is installed instead — fails at Dagger codegen.