fix(data): an entity event is recorded with its write, not published after it (#6816) - #6854
Open
delchev wants to merge 1 commit into
Open
fix(data): an entity event is recorded with its write, not published after it (#6816)#6854delchev wants to merge 1 commit into
delchev wants to merge 1 commit into
Conversation
| .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>
delchev
force-pushed
the
fix/event-outbox
branch
from
August 19, 2026 18:00
edb0cde to
304a49a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
500reached 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_OUTBOXon 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, andEventOutboxRelayJobdrains it per tenant until the broker takes it.A repository reaches this by handing its topic to the write instead of publishing beside it:
saved = super.save(entity);Producer.sendToTopic(topic, Json.stringify(saved));saved = super.save(entity, topic);super.update(entity)+ one or twosendToTopicsuper.update(entity, topic[, rekeyEvents])updateProperties(id, values)then re-read + publishupdateProperties(id, values, topic)super.delete(entity)/super.deleteById(id)+ publishsuper.delete(entity, topic)/super.deleteById(id, topic)Producer.sendToTopicis untouched and stays the raw messaging API — the outbox is reached only by giving a write its topic.Worth knowing
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 amultilingual: trueentity that means the untranslated row — an event carries canonical data, not the writer'sAccept-Language. This is a small, deliberate change from the previous behaviour, whereupdateDerivedanddeleteByIdpublished through the repository's translatingfindById.recalculatereaches it throughsuperprecisely to bypass those semantics.DIRIGIBLE_EVENT_OUTBOX_RELAY_INTERVAL_SECONDS(30) andDIRIGIBLE_EVENT_OUTBOX_RELAY_GRACE_SECONDS(60) — the window the in-process dispatch owns an entry for before the relay may take it over.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,JavaRepositoryFindByIdITgreen locally; fullquick-buildgreen.IntentEngineIT's create-event assertion was updated to the new shape (the SDKJsonhelper still serializes the payload — the platform applies it now instead of every generated repository pasting it).ModelGenerationIThits the knownsun.nio.fs.PollingWatchServicedeadlock inLocalRegistryWatcher.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-javaguide and the rootCLAUDE.mdcarry the contract.🤖 Generated with Claude Code