Skip to content

fix(data): an entity event is recorded with its write, not published after it (#6816) - #6854

Open
delchev wants to merge 1 commit into
masterfrom
fix/event-outbox
Open

fix(data): an entity event is recorded with its write, not published after it (#6816)#6854
delchev wants to merge 1 commit into
masterfrom
fix/event-outbox

Conversation

@delchev

@delchev delchev commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Closes #6816.

The problem

The generated repositories committed the row and only then called the broker, with no shared transaction and no catch. A broker that was briefly unavailable therefore did two things at once:

  • the event was lost for good — nothing retried it, so every event-driven handler (process triggers, roll-ups, postings, notifications) simply never saw that record; and
  • a 500 reached the REST caller whose write had actually succeeded, inviting a retry that duplicated the record.

The fix — a transactional outbox

The event is recorded in the tenant's DIRIGIBLE_EVENT_OUTBOX on the write's own connection, inside its transaction, so the row and its event commit or roll back together. Straight after the commit the same thread hands it to the broker, so a healthy system publishes exactly as promptly as before. Whatever it cannot deliver simply stays in the table, and EventOutboxRelayJob drains it per tenant until the broker takes it.

A repository reaches this by handing its topic to the write instead of publishing beside it:

before now
saved = super.save(entity);
Producer.sendToTopic(topic, Json.stringify(saved));
saved = super.save(entity, topic);
super.update(entity) + one or two sendToTopic super.update(entity, topic[, rekeyEvents])
updateProperties(id, values) then re-read + publish updateProperties(id, values, topic)
super.delete(entity) / super.deleteById(id) + publish super.delete(entity, topic) / super.deleteById(id, topic)

Producer.sendToTopic is untouched and stays the raw messaging API — the outbox is reached only by giving a write its topic.

Worth knowing

  • Delivery is at-least-once, not exactly-once. "Sent" is only known once the entry is gone, so an entry published just before the node died is published again. The generated glue already tolerates a repeat: it recomputes from the store rather than accumulating.
  • No outbox, no write. If the entry cannot be recorded the transaction fails — which is the contract: a row whose event was never recorded is exactly the state this replaces.
  • The payload is the row as the transaction left it. For the targeted (updateDerived) path it is read back on the write's own connection before the commit, never a re-read afterwards that a concurrent write could have moved on. On a multilingual: true entity that means the untranslated row — an event carries canonical data, not the writer's Accept-Language. This is a small, deliberate change from the previous behaviour, where updateDerived and deleteById published through the repository's translating findById.
  • A repository that overrides targeted writes now overrides the event-carrying form, with the plain two-argument form delegating to it. The base two-argument form deliberately does not re-dispatch, because a document master's recalculate reaches it through super precisely to bypass those semantics.
  • Two tunables: DIRIGIBLE_EVENT_OUTBOX_RELAY_INTERVAL_SECONDS (30) and DIRIGIBLE_EVENT_OUTBOX_RELAY_GRACE_SECONDS (60) — the window the in-process dispatch owns an entry for before the relay may take it over.
  • Sibling issues this does not close: Client-Java listener path has no retry and no dead-letter - and makes the posting engine's resumability contract unreachable #6803 (no retry/dead-letter on the consumer side) and Client-Java topic subscriptions are non-durable - events published during restart or republish are lost #6804 (non-durable topic subscriptions). The outbox makes the producer side durable; a message published to a topic with no live subscriber is still dropped by the broker, so Client-Java topic subscriptions are non-durable - events published during restart or republish are lost #6804 stays open on its own terms.

Verification

  • JavaEventOutboxIT (new) covers both halves through the real machinery: an ordinary create from a client repository reaching its listener with nothing left in the outbox, and an entry planted the way a broker outage would have left one being delivered by the relay and cleared.
  • IntentEngineIT (50), IntentEmissionCoverageIT, JavaEngineIT, JavaBpmnIT, JavaRepositoryFindByIdIT green locally; full quick-build green. IntentEngineIT's create-event assertion was updated to the new shape (the SDK Json helper still serializes the payload — the platform applies it now instead of every generated repository pasting it).
  • The rest of the local smoke suite could not be completed on macOS: ModelGenerationIT hits the known sun.nio.fs.PollingWatchService deadlock in LocalRegistryWatcher.closeWatchService, which is environment-specific and unrelated to this change (Linux CI uses inotify).

No DSL surface changed, so there is no intentfile / dirigible.io page to follow; the in-repo engine-java guide and the root CLAUDE.md carry the contract.

🤖 Generated with Claude Code

.column(quoted(COLUMN_NEXT_ATTEMPT_AT), DataType.TIMESTAMP, false, false, false)
.column(quoted(COLUMN_ERROR), DataType.VARCHAR, false, true, false, "(2000)")
.build();
try (PreparedStatement statement = connection.prepareStatement(sql)) {
.build();
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.executeUpdate();
LOGGER.info("Created the event outbox table using sql [{}]", sql);
…after it (#6816)

The generated repositories committed the row and only then called the
broker, with no shared transaction and no catch. A broker that was
briefly unavailable therefore did two things at once: it lost the event
for good, since nothing retried it, and it raised to the REST caller
whose row had actually been written - inviting a retry that duplicated
the record.

The event now goes into the tenant's DIRIGIBLE_EVENT_OUTBOX on the
write's own connection, inside its transaction, so the row and its event
commit or roll back together. Straight after the commit the same thread
hands it to the broker, so a healthy system publishes exactly as
promptly as before; whatever it cannot deliver simply stays in the
table, and EventOutboxRelayJob drains it per tenant until the broker
takes it. Delivery is at-least-once - "sent" is only known once the
entry is gone - which the generated glue already tolerates, since it
recomputes from the store rather than accumulating.

A repository reaches this by handing its topic to the write rather than
publishing beside it: save(entity, topic), update(entity, topic,
extraEvents), updateProperties(id, values, topic), delete(entity, topic)
and deleteById(id, topic). The payload is the row as the transaction
left it - for the targeted path read back on the write's own connection
before the commit, never a re-read afterwards that a concurrent write
could have moved on. Producer.sendToTopic is unchanged and stays the raw
messaging API.

Two tunables: DIRIGIBLE_EVENT_OUTBOX_RELAY_INTERVAL_SECONDS (30) and
DIRIGIBLE_EVENT_OUTBOX_RELAY_GRACE_SECONDS (60), the window the
in-process dispatch owns an entry for before the relay may take it.

JavaEventOutboxIT covers both halves: an ordinary create reaching its
listener, and an entry only the relay can deliver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The row is committed before its event is published - a broker failure loses the event and 500s a successful write

2 participants