Skip to content

intent: the posting rewrite is one transaction - a refused line leaves the previous post standing (#7132) - #7167

Merged
delchev merged 1 commit into
masterfrom
issue-7132-posting-atomic
Sep 8, 2026
Merged

intent: the posting rewrite is one transaction - a refused line leaves the previous post standing (#7132)#7167
delchev merged 1 commit into
masterfrom
issue-7132-posting-atomic

Conversation

@delchev

@delchev delchev commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

The defect

The #7071 rewrite path in Posting.java.template deleted every stale item row, updated the header and then saved the derived lines - each store call its own transaction. A failure on the Nth line (now easy to reach: #7069's required-value check throws a ValidationException from the item save) left the journal entry with a header and a partial set of lines.

Unlike the header/items half-post case there is no guaranteed second event to self-heal from - the source has already reached its moment and raises nothing further - so the ledger ended up worse than the stale but balanced state the rewrite set out to fix.

The fix

The handler's whole write phase runs in one UnitOfWork.run(...), as Generate.java.template has since #7069: the rows a rewrite replaces, the header and every derived line either all become durable or none of them does. Either the post says what the source derives now, or it still says what it said before.

Mechanically it is small: currentItems is hoisted out of the refresh branch (both branches assign it exactly once, so the block can close over it) and the delete / update-or-save / line-save move into the block. The decision half stays outside on purpose - the cell-by-cell comparison, the redelivery no-op and the amendable guard only read, and they return before anything is written.

Across steps the consistency model is unchanged and deliberately not transactional: the source's own commit, this handler's post and a reversal are separate events, and a bad post is unwound by a correcting entry (reverses:), not a rollback. The prose that claimed "no cross-step transaction" as a property of the post itself is corrected in the template header, the module guide and the docs index.

Verification

  • IntentEngineIT - 71/71 green. Asserts by position that the stale-row delete, the header write and the line writes all sit inside the block; a header written outside it with the lines inside would still "mention UnitOfWork".
  • IntentEmissionCoverageIT - green. Same positional assertions, and it compiles the generated handler with the real javac batch, which is what proves the lambda's captures are legal.
  • mvn formatter:validate clean.

Docs: dirigible-io/dirigible-io.github.io#239.

Noted, not fixed here

Posts.java.template's per-item mode has the same shape and is arguably worse: it writes N flat rows, one transaction each, and its idempotency guard is "any row back-references this source" - so a failure on row 3 leaves rows 1-2 and every redelivery afterwards is a no-op, making the half-post permanent. Out of scope for this issue, which names the posting rewrite; worth its own.

Fixes #7132

🤖 Generated with Claude Code

…s the previous post standing (#7132)

The #7071 rewrite path deleted every stale item row, updated the header and
then saved the derived lines, each store call its own transaction. A failure
on the Nth line - now easy to reach, since #7069's required-value check
throws a ValidationException from the item save - left the journal entry
with a header and a partial set of lines. Unlike the half-post case there
is no second event to self-heal from: the source has already reached its
moment and raises nothing further, so the ledger ended up WORSE than the
stale but balanced state the rewrite set out to fix.

The handler's whole write phase now runs in one
UnitOfWork.run(...) - the rows a rewrite replaces, the header, every derived
line - as Generate.java.template has since #7069: either the post says what
the source derives now, or it still says what it said before. Across STEPS
the consistency model is unchanged and deliberately not transactional; a bad
post is unwound by a correcting entry (reverses:), not a rollback.

Mechanically this only hoists currentItems out of the refresh branch (both
branches assign it once, so the block can close over it) and moves the
delete/update/save into the block. The decision half - the cell-by-cell
comparison, the redelivery no-op, the amendable guard - stays outside: it
only reads, and it returns before anything is written.

Covered by IntentEngineIT and IntentEmissionCoverageIT, both asserting by
POSITION that the delete, the header write and the line writes sit inside
the block - a header written outside it with the lines inside would still
"mention UnitOfWork". IntentEmissionCoverageIT additionally compiles the
generated handler with the real javac batch.

Fixes #7132

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@delchev
delchev merged commit 9d6b8d5 into master Sep 8, 2026
10 checks passed
@delchev
delchev deleted the issue-7132-posting-atomic branch September 8, 2026 09:10
delchev added a commit that referenced this pull request Sep 10, 2026
…eaves nothing (#7179) (#7245)

Posts.java.template's per-item mode (posts: with forEach:) had the same
multi-write shape PR #7167 fixed in the document-shaped posting and no unit of
work: one save per derived row, one transaction each. A row the target
repository refused - a validation, a constraint, a required column the derived
row leaves null - left the rows before it durable.

That is worse here than in the posting, because this mode's idempotency guard
is coarser: it asks whether ANY row back-references the source, so the partial
set read as a finished post and every redelivery afterwards was a no-op. The
missing rows were never written by anything. The half-post was PERMANENT.

The rows one source event derives are now mapped in memory first and written
inside one UnitOfWork.run(...): all of them commit or none does, so a failed
tick leaves nothing and the redelivery writes the whole set. That also makes
the existing guard exact rather than needing to be replaced - with atomicity
the back-reference is present for a whole post and absent for no post, and
there is no partial set left for it to misread. The single-row mode keeps its
one save: one repository call is already one transaction.

Verification is a new IntentPostsAtomicityIT, the runtime half this mode never
had - it publishes the app (which compiles the generated handler) and drives
the sequence the issue describes: a goods issue whose second line carries no
quantity derives a movement the target refuses, the ledger must hold NOTHING,
the line is then repaired and the event redelivered, and the ledger must hold
the WHOLE post. Against the unfixed template that run ends with exactly one
durable row and stays there. IntentEngineIT covers the emission by POSITION
(derivation before the unit, the sole save site inside it, no unit in the
single-row mode) - the flat mode had no rendering coverage at all until now.

Fixes #7179

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.

intent: the posting rewrite is not atomic - a failed item save leaves a partial journal entry

1 participant