What is left in the system if you die halfway

An operation that spans several services is not one database write. Reserve capacity, claim a quota, apply the change, hand it to a downstream system, notify. Any step can refuse, and by then some of the earlier ones are already irreversible.

try/catch does not help here. What needs undoing is not a transaction but actions that already happened, in reverse order, and only the ones that really did.

petich is built around that one question: what is left in the system if you die halfway.

One interceptor, one step forward and one step back

A step declares both directions:

execute()    — do the thing
compensate() — undo the thing you did

Steps run in phases — ENRICHMENT → VALIDATION → AUTHORIZATION → EXECUTION → POST_PROCESSING — ordered by priority within a phase. A failure at step N calls compensate() on N−1 … 1, in reverse.

Putting both halves in one object is the point. When undo lives somewhere else, it drifts from do, and it drifts silently: nobody runs the compensation path on the happy day.

Waiting for a human without holding anything

Some sagas stop and wait for a person to confirm. The naive implementation holds a thread, or a database connection, or both, for however long that takes.

petich suspends: the saga is a value, it goes to storage, and it resumes on a later HTTP request. Nothing is held in the meantime.

Which immediately raises the real question — what about the one nobody comes back to? A suspended saga has already claimed resources. Left alone it holds them forever. So the wait has a deadline, and a background sweeper rolls back what expired.

That pair matters more than the suspend itself. Suspension without a deadline is a resource leak with better manners.

The outbox is the interesting part

The classic distributed failure is not a crash. It is this: the work committed, and the event announcing it did not. Downstream never finds out, and no error was raised anywhere.

With an outbox-aware repository — petich-postgres is one — the intent to emit is written in the same transaction as the state change. Either both land or neither does. There is no window.

A repository without that support still works. The engine falls back to a plain update and drops the events, which is a deliberate choice: degrade visibly to "no events" rather than pretend to a guarantee the storage cannot make.

Races are handled by optimistic locking on a version, plus a per-saga mutex inside the process.

Three modules that do not depend on the core

module what for
petich-core the engine: sagas, interceptors, phases, compensation, suspend/resume, TTL
petich-ktor REST endpoints for creating and resuming a saga
petich-postgres storage on Exposed — outbox, idempotency, scheduler
petich-outbox-core at-least-once delivery with backoff and dead lettering
petich-idempotency protection against a key reused with a different request
petich-scheduler a saga on a schedule, with no HTTP initiator

The last three know nothing about sagas. petich-outbox-core knows only about a row — id, type, payload, deliver at least once. petich-scheduler knows "it is time" and "here is the payload". petich-idempotency knows "this key already arrived with a different fingerprint".

That independence is not tidiness. It is the condition under which they stay usable on their own instead of turning into part of somebody's feature.

petich-postgres also ships no driver and no connection pool. It takes an Exposed Database and does not care what is underneath it — choosing a driver is the application's decision, not a library's.


Source: github.com/youndie/petich