Skip to content

feat: SAGA#46

Open
sjvans wants to merge 12 commits into
mainfrom
saga
Open

feat: SAGA#46
sjvans wants to merge 12 commits into
mainfrom
saga

Conversation

@sjvans

@sjvans sjvans commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

xflights sibling: capire/xflights#9


The Saga Pattern Story

The xtravels app demonstrates a choreography-based saga for distributed travel booking, spanning two microservices: xtravels (the booking owner) and xflights (the seat inventory manager).

The Flow

Step 1 — Local commit with pending state
When a customer saves a Travel with Bookings, xtravels commits the data locally. Each Booking is initially in an unresolved state — neither confirmed nor failed.

Step 2 — Outboxed event emission
After the save, xtravels emits a BookingCreated event to xflights via the transactional outbox (cds.outboxed(xflights)). The outbox decouples the emission from the local transaction: the event is durably stored and delivered reliably, even if xflights is temporarily unavailable.

this.after('SAVE', Travels, (_, req) => {
  const { Bookings = [] } = req.data
  return Promise.all(Bookings.map(booking => {
    let { Flight_ID: flight, Flight_date: date, Travel_ID, Pos } = booking
    return yfligths.emit('BookingCreated', { flight, date }, { Travel_ID, Pos })
  }))
})

Step 3 — Remote processing
xflights receives the event, checks seat availability, and updates the occupied seat count. If no seats are available it calls req.reject(...), which causes the outbox runtime to emit BookingCreated/#failed back to the caller; on success it emits BookingCreated/#succeeded.

Step 4a — Confirmation
On success, xtravels updates the Booking status to Confirmed ('C'):

xflights.after('BookingCreated/#succeeded', async (_, req) => {
  const { Travel_ID, Pos } = req.headers
  await UPDATE(Bookings, { Travel_ID, Pos }).set({ Status_code: 'C' })
})

Step 4b — Failure recording
On failure (e.g. no seats available), xtravels marks the Booking as Failed ('F'). This is not a rollback — the booking record was never confirmed, so there is nothing to undo. Instead the failure is recorded explicitly, leaving it visible to the user for manual resolution or retry:

xflights.after('BookingCreated/#failed', async (err, req) => {
  const { Travel_ID, Pos } = req.headers
  await UPDATE(Bookings, { Travel_ID, Pos }).set({ Status_code: 'F' })
})

Why this is a Saga

There is no distributed transaction. Instead, each service owns its local data and emits events. Consistency is achieved eventually through the event chain:

  • xtravels commits first, independently
  • xflights reacts asynchronously
  • xtravels reconciles booking status based on the outcome

The transactional outbox (cds.outboxed) is what makes this reliable: the event is guaranteed to be delivered exactly once, even across restarts, so the saga never gets stuck in a half-sent state.

The compensation (setting Status_code: 'F') is explicit and domain-meaningful — the booking was never confirmed, so there is nothing to roll back. Recording the failure leaves it visible to the user, enabling manual resolution or retry.

Replicated Flight Data — Keeping Free Seats in Sync

xtravels keeps a local replica of flight data (including free_seats) for fast value-help lookups without round-tripping to xflights on every request.

The replica is initialized and kept current via two complementary mechanisms:

Scheduled delta replication (every 10 minutes)
The Flights entity in xtravels is annotated @federated. The generic data-federation.js module picks up all @federated entities at startup, turns them into local persistence tables, and schedules a periodic delta replication:

await srv.schedule('replicate', each).as(each.entity).every('10 minutes')

Each run reads only rows modified since the last replication (modifiedAt > latest) and upserts them locally — a low-traffic catch-all that ensures the replica stays consistent even if events are missed.

Real-time updates via FlightsUpdated events
xflights emits FlightsUpdated events whenever seat counts change, and xtravels subscribes to keep its replica current — only when it actually holds a local persistence table for Flights:

if (Flights['@cds.persistence.table']) xflights.on('FlightsUpdated', async function(msg) {
  const { flight: ID, date } = msg.data
  // re-read current free seats from xflights (messages can overtake each other,
  // so don't trust a seat count carried in the payload)
  const { free_seats } = await this.read(Flights, { ID, date }).columns('free_seats')
  await UPDATE(Flights, { ID, date }).with({ free_seats })
})

The event is treated as a pure notification ("something changed"). Rather than trusting a seat count carried in the payload — which could be stale if two updates arrive out of order — xtravels re-reads the authoritative, current value from xflights.

The CAP Primitives at Work

Concern Mechanism
Reliable event delivery cds.outboxed(xflights)
Async callback routing CAP outbox runtime → BookingCreated/#succeeded / /#failed
Failure recording UPDATE ... set Status_code: 'F'
Replica baseline sync @federated + delta replication every 10 min
Replica real-time sync xflights.on('FlightsUpdated', ...) + re-read

The pattern keeps each service's data locally consistent at all times. Cross-service consistency emerges from the event chain — no distributed transactions, no central orchestrator.

@sjvans
sjvans requested a review from danjoa June 18, 2026 09:38
@sjvans
sjvans marked this pull request as ready for review June 18, 2026 09:38
Comment thread db/schema.cds Outdated
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.

2 participants