Skip to content

feat: declarative rollback rules on #[transactional] (26.6.27)#29

Merged
ancongui merged 1 commit into
mainfrom
feat/transactional-rollback-rules
Jun 16, 2026
Merged

feat: declarative rollback rules on #[transactional] (26.6.27)#29
ancongui merged 1 commit into
mainfrom
feat/transactional-rollback-rules

Conversation

@ancongui

Copy link
Copy Markdown
Contributor

What

A Spring Boot parity increment (26.6.27): declarative transaction rollback rules on #[transactional].

Picked from a 16-area parity-gap analysis (the framework sits at ~84% overall Spring parity) as the best value-to-effort gap — the transaction runtime already supported per-error rollback decisions (transactional_with / transactional_with_on take a should_rollback(&E) -> bool), so only the macro surface was missing.

The feature

// Persist the audit row even when the domain rejects the charge, but still roll
// back on any infrastructure failure — @Transactional(noRollbackFor = …).
#[firefly::transactional(no_rollback_for = "BillingError::Rejected(_)")]
async fn charge(&self, req: Charge) -> Result<Receipt, BillingError> {}

Spring names exception types; because Rust's Result already separates failure from success, the Firefly analog names an error pattern. By default every Err rolls back; then:

  • no_rollback_for = "P"Spring's @Transactional(noRollbackFor = …): an Err matching P commits instead of rolling back;
  • rollback_only_for = "P": roll back only for errors matching P, committing the rest;
  • with both, no_rollback_for wins on overlap.

Patterns allow A | B alternatives (no if guard). The generated predicate is matches!-based, so a pattern that doesn't fit the error type is a compile error. Composes with manager = "…" (the 2×2 routing across explicit-manager / process-global × rule / no-rule).

Fidelity note (why it's rollback_only_for, not rollback_for)

Spring's rollbackFor is additive — it widens the set of exceptions that roll back (checked exceptions don't, by default). Rust has no checked/unchecked split: every Err already rolls back, so an additive rule would be a no-op. The faithful rule here is therefore restrictive, and it is named rollback_only_for so a Spring migrant can't write rollback_for and silently invert their rollback behavior. Writing rollback_for is a friendly compile error pointing at the two correct rules.

Review

Adversarially reviewed before merge; the review caught and I fixed:

  • the rollback_for naming footgun (→ rollback_only_for + intercept-with-error),
  • the no-if-guard constraint (doc + parser),
  • an untested process-global path (added a test).

Tests & docs

  • A spy TransactionManager records the per-call commit-vs-rollback decision across all four cases (default / no_rollback_for / rollback_only_for / both-with-overlap) on both the explicit-manager and process-global paths — 5 tests, plus the existing trybuild UI tests.
  • Docs: 07-persistence.md (with a "Not rollback_for" callout) + CHANGELOG.
  • make ci green: 316 suites, 4489 tests, 0 failed.

First parity increment after the docs-correctness arc (#26–28). The roadmap's next-highest value lever is declarative @HttpExchange HTTP-interface clients.

A Spring Boot parity increment, chosen from a 16-area parity-gap analysis
(~84% overall) as the best value-to-effort gap — the transaction runtime
already supported per-error rollback decisions; only the macro surface was
missing.

#[transactional(no_rollback_for = "<pat>", rollback_only_for = "<pat>")]
  Spring names exception *types*; because Rust's Result already separates
  failure from success, the Firefly analog names an error *pattern*. By default
  every Err rolls back; then:
  - no_rollback_for = "P" — Spring's @transactional(noRollbackFor = …): an Err
    matching pattern P commits instead of rolling back;
  - rollback_only_for = "P": roll back only for errors matching P, committing
    the rest;
  - with both, no_rollback_for wins on overlap.

  The macro lowers to the already-present transactional_with /
  transactional_with_on runtime entry points (which take a
  should_rollback(&E) -> bool predicate), composes with manager = "…", and the
  generated predicate is matches!-based, so a pattern that does not fit the
  error type is a compile error. Patterns allow `A | B` alternatives (no `if`
  guard).

  Deliberately NOT named rollback_for: Spring's rollbackFor is *additive* (it
  widens the always-rollback set), but Rust has no checked/unchecked split —
  every Err already rolls back — so the faithful rule is *restrictive*. Writing
  rollback_for is a friendly compile error pointing at the two rules above, so a
  Spring port can't be silently inverted.

Adversarially reviewed (caught and fixed the rollback_for naming footgun, the
no-guard constraint, and an untested process-global path before merge). Tests:
a spy TransactionManager records the per-call commit-vs-rollback decision across
all four cases on both the explicit-manager and process-global paths. Docs:
07-persistence.md + CHANGELOG. make ci green: 316 suites, 4489 tests, 0 failed.
@ancongui ancongui merged commit b746625 into main Jun 16, 2026
@ancongui ancongui deleted the feat/transactional-rollback-rules branch June 16, 2026 15:50
ancongui added a commit that referenced this pull request Jun 16, 2026
#31)

The published dist (firefly-rust-by-example.{pdf,epub}) was stale — built
before the README/book audits (#26/#27) and the new framework features
(declarative rollback rules #29, #[http_client] #30). Regenerated against
current content and completed the manifest.

- book.yaml: added the three chapters that existed in src/ but were omitted
  from the curated PDF manifest, renumbered the book contiguously 1–26:
  - ch6  "Application Bootstrap"        (04b-bootstrap.md, Part I)
  - ch9  "OpenAPI & API Documentation"  (06a-openapi.md, Part II)
  - ch18 "Layered Microservices"        (22-layered-microservices.md, Part IV)
  (introduction.md stays excluded — the PDF has its own front matter.)
- gen_openers.py: three new on-brand 720x300 chapter openers (s_bootstrap,
  s_openapi, s_layered) in the established style; switched the kicker numbers
  to an explicit reading-order list so every "CHAPTER N" matches book.yaml.
  The 18 ch06–ch23 openers changed only because their kicker number shifted;
  ch01–ch05 are byte-identical.
- dist: regenerated PDF (485 -> 519 pages, +3 chapters) and EPUB. The new
  #[http_client] (ch13) and #[transactional] rollback-rules (ch8) sections
  now render in the published book.

Built with the WeasyPrint pipeline (docs/book/build-book.sh; venv +
weasyprint 69). Docs-only — no crate or version change.

Co-authored-by: Andrés Contreras Guillén <ancongui@Andress-MacBook-Pro-2.local>
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.

1 participant