§ reference

eventlog.event_notifications

Per-handler delivery rows used by the local event handler.

eventlog.event_notifications is optional. You only need it when the local event handler module is installed. It turns one durable event row into one delivery row per subscribed handler.

Example: if WalletMoneyDepositedEvent has two handlers, EventFanoutJob creates two notification rows with the same event_id and different handler_name values.

Required when: only when you use ekbatan-local-event-handler, either to handle events locally or to publish from a local handler.

Writer: EventFanoutJob creates rows; EventHandlingJob updates rows.

Row model: io.ekbatan.events.localeventhandler.model.EventNotification.

EventNotification is an internal row representation, not a domain Model or Entity. The handling jobs update rows directly as delivery state changes.

DDL

This DDL assumes the base eventlog.events table already exists. The first index below is on eventlog.events; it is part of the local-handler setup because fan-out polls undelivered event rows.

src/main/resources/db/migration/ V0001__eventlog.sql
sql
1 CREATE INDEX events_undelivered
2 ON eventlog.events (event_type, event_date)
3 WHERE delivered = FALSE;
4
5 CREATE TABLE eventlog.event_notifications (
6 id UUID PRIMARY KEY,
7 event_id UUID NOT NULL,
8 handler_name VARCHAR(255) NOT NULL,
9 namespace VARCHAR(255) NOT NULL,
10 action_id UUID NOT NULL,
11 action_name VARCHAR(255) NOT NULL,
12 action_params JSONB NOT NULL,
13 started_date TIMESTAMP NOT NULL,
14 completion_date TIMESTAMP NOT NULL,
15 model_id VARCHAR(255),
16 model_type VARCHAR(255),
17 event_type VARCHAR(255) NOT NULL,
18 payload JSONB,
19 event_date TIMESTAMP NOT NULL,
20 state VARCHAR(24) NOT NULL,
21 attempts INT NOT NULL DEFAULT 0,
22 next_retry_at TIMESTAMP NOT NULL,
23 created_date TIMESTAMP NOT NULL,
24 updated_date TIMESTAMP NOT NULL,
25 UNIQUE (event_id, handler_name)
26 );
27
28 CREATE INDEX event_notifications_due
29 ON eventlog.event_notifications (next_retry_at)
30 WHERE state IN ('PENDING', 'FAILED');
src/main/resources/db/migration/ V0001__eventlog.sql
sql
1 CREATE INDEX events_pending_fanout
2 ON eventlog.events (delivered, event_type, event_date);
3
4 CREATE TABLE eventlog.event_notifications (
5 id UUID PRIMARY KEY,
6 event_id UUID NOT NULL,
7 handler_name VARCHAR(255) NOT NULL,
8 namespace VARCHAR(255) NOT NULL,
9 action_id UUID NOT NULL,
10 action_name VARCHAR(255) NOT NULL,
11 action_params JSON NOT NULL,
12 started_date DATETIME(6) NOT NULL,
13 completion_date DATETIME(6) NOT NULL,
14 model_id VARCHAR(255),
15 model_type VARCHAR(255),
16 event_type VARCHAR(255) NOT NULL,
17 payload JSON,
18 event_date DATETIME(6) NOT NULL,
19 state VARCHAR(24) NOT NULL,
20 attempts INT NOT NULL DEFAULT 0,
21 next_retry_at DATETIME(6) NOT NULL,
22 created_date DATETIME(6) NOT NULL,
23 updated_date DATETIME(6) NOT NULL,
24 UNIQUE (event_id, handler_name)
25 );
26
27 CREATE INDEX event_notifications_due ON eventlog.event_notifications (next_retry_at);
src/main/resources/db/migration/ V0001__eventlog.sql
sql
1 CREATE INDEX events_pending_fanout
2 ON eventlog.events (delivered, event_type, event_date);
3
4 CREATE TABLE eventlog.event_notifications (
5 id CHAR(36) CHARACTER SET ascii NOT NULL,
6 event_id CHAR(36) CHARACTER SET ascii NOT NULL,
7 handler_name VARCHAR(255) NOT NULL,
8 namespace VARCHAR(255) NOT NULL,
9 action_id CHAR(36) CHARACTER SET ascii NOT NULL,
10 action_name VARCHAR(255) NOT NULL,
11 action_params JSON NOT NULL,
12 started_date DATETIME(6) NOT NULL,
13 completion_date DATETIME(6) NOT NULL,
14 model_id VARCHAR(255),
15 model_type VARCHAR(255),
16 event_type VARCHAR(255) NOT NULL,
17 payload JSON,
18 event_date DATETIME(6) NOT NULL,
19 state VARCHAR(24) NOT NULL,
20 attempts INT NOT NULL DEFAULT 0,
21 next_retry_at DATETIME(6) NOT NULL,
22 created_date DATETIME(6) NOT NULL,
23 updated_date DATETIME(6) NOT NULL,
24 PRIMARY KEY (id),
25 UNIQUE (event_id, handler_name)
26 );
27
28 CREATE INDEX event_notifications_due ON eventlog.event_notifications (next_retry_at);

Columns

ColumnNullableMeaning
idNoPrimary key of this notification row. One event can have many notification rows, one per subscribed handler.
event_idNoSource eventlog.events.id. It is intentionally not enough by itself to be unique because several handlers can consume the same event.
handler_nameNoCluster-stable handler identifier from EventHandler.name(). Treat this like schema data: renaming it without aliases() makes old queued rows invisible to the new handler name.
namespaceNoCopied from eventlog.events.namespace at fan-out time.
action_idNoCopied from the source event row.
action_nameNoCopied from the source event row.
action_paramsNoCopied from the source event row.
started_dateNoCopied from the source event row.
completion_dateNoCopied from the source event row.
model_idYesCopied from the source event row. Usually non-null because sentinel rows are not fanned out, but the column stays nullable to mirror the source event shape.
model_typeYesCopied from the source event row. Usually non-null for the same reason as model_id.
event_typeNoEvent simple class name used to route to a typed handler. Sentinel rows are skipped before notification creation, so this is required.
payloadYesEvent payload copied from the source event row. It can be NULL only if a malformed source row exists; normal emitted events have a payload.
event_dateNoCopied from the source event row.
stateNoDelivery lifecycle: PENDING, FAILED, SUCCEEDED, or EXPIRED.
attemptsNoNumber of failed delivery attempts so far. Defaults to 0 in the DDL.
next_retry_atNoEarliest UTC instant when EventHandlingJob may try this notification again.
created_dateNoUTC instant when EventFanoutJob created the notification row.
updated_dateNoUTC instant when the notification row was last updated.

The unique constraint on (event_id, handler_name) makes fan-out idempotent per handler. If the fan-out job sees the same event again, it cannot create duplicate work for the same handler.

State transitions

StateMeaning
PENDINGInitial state. The handler has not succeeded or failed yet.
FAILEDA handler attempt threw and another retry is still allowed.
SUCCEEDEDTerminal state. The handler returned normally.
EXPIREDTerminal state. The retry window elapsed without a successful handler run.

Indexes

IndexDialectPurpose
events_undelivered on eventlog.events(event_type, event_date) with WHERE delivered = FALSEPostgreSQLLets EventFanoutJob find undelivered rows for event types that currently have registered handlers.
events_pending_fanout on eventlog.events(delivered, event_type, event_date)MySQL/MariaDBSame purpose as the PostgreSQL partial index; MySQL and MariaDB do not support PostgreSQL-style partial indexes.
event_notifications_due on eventlog.event_notifications(next_retry_at) with WHERE state IN ('PENDING', 'FAILED')PostgreSQLLets EventHandlingJob find due retryable rows without scanning terminal rows.
event_notifications_due on eventlog.event_notifications(next_retry_at)MySQL/MariaDBThe state predicate stays in the query because partial indexes are not available.

Handler names

handler_name stores EventHandler.name(), so treat it like durable schema data. If you rename a handler, use aliases() with the previous name while old rows drain. New fan-out rows are written with the canonical name(), and old queued rows can still route to the renamed handler through the alias.

See also