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