
Distributed saga engine handling multi-step operations via interceptor chains across ordered phases; automatic compensation, suspend/resume with TTL, optimistic locking, and outbox-aware reliable event delivery.
a distributed saga engine for Kotlin — a multi-step operation is described as a chain of interceptors; the engine walks it through phases and, when any step fails, undoes exactly what had already happened
🔁 one interceptor → one step forward and one step back
Built around a single question: what is left in the system if you die halfway.
Why it is built this way, and what is being worked on: docs/ and the
backlog.
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 some are already
irreversible by then. An ordinary try/catch does not help — what needs undoing is not a database
transaction but actions already performed, in reverse order, and only those that really happened.
The engine takes on exactly that:
ENRICHMENT → VALIDATION → AUTHORIZATION → EXECUTION → POST_PROCESSING,
with steps inside a phase ordered by priority;compensate() on steps N−1 … 1, in reverse;petich-postgres is one) the intent
to emit an event is written in the SAME transaction as the state change, which makes "the work
happened but the notification never went out" structurally impossible. A repository without that
support still works; the engine falls back to a plain update and drops the events. That fallback
is now countable and refusable: PetichEngineMetrics.onDroppedEvents fires on every event lost
this way, and PetichEngineConfig(requireOutbox = true) refuses to build an engine whose
repository cannot store them at all. Both are off by default, so a deliberately outbox-free
application changes nothing; anything wiring the outbox to a broker wants the second one, because
the drop is otherwise invisible — the saga completes and its state is correct.| module | what for | targets | depends on |
|---|---|---|---|
petich-core |
the engine: sagas, interceptors, phases, compensation, suspend/resume, TTL | jvm, linuxX64 | — |
petich-ktor |
REST endpoints for creating and resuming a saga | jvm, linuxX64 | petich-core |
petich-postgres |
storage on Exposed | jvm only — Exposed over JDBC, and JDBC is a JVM interface rather than a protocol | core, outbox, idempotency, scheduler |
petich-outbox-core |
at-least-once event delivery with backoff and dead lettering | jvm, linuxX64 | — |
petich-idempotency |
protection against a key reused with a DIFFERENT request | jvm, linuxX64 | — |
petich-scheduler |
a saga on a schedule, starting with no HTTP initiator | jvm, linuxX64 | — |
petich-chronik |
a fired chronik timer resumes a suspended saga | jvm, linuxX64 — needs chronik 0.2.0 or newer | petich-core |
petich-conformance |
the rules a storage implementation has to satisfy, as cases you can run against yours | jvm, linuxX64 | core, outbox, idempotency, scheduler |
petich-sqlx4k-postgres |
the same storage contracts over sqlx4k, for a service with no JVM; brings no driver | jvm, linuxX64 | core, outbox, idempotency, scheduler |
A Kotlin/Native service can take the engine, its HTTP surface, the three independent modules and a
store: petich-sqlx4k-postgres implements the same four contracts over sqlx4k and is accepted by
the corpus in petich-conformance on both targets. petich-postgres stays where it is — Exposed
over JDBC — and both write the same columns, so a service can move one process at a time. See
docs/.
Three modules deliberately do not depend on the core. petich-outbox-core knows only about a row —
"id/type/payload, deliver at least once"; petich-scheduler only about "it is time" and "here is the
payload"; petich-idempotency only about "this key already arrived with a different fingerprint".
Each is usable on its own, and that is not an accident but the condition under which they do not turn
into part of somebody's feature.
repositories {
mavenCentral()
}
dependencies {
implementation("io.github.youndie.petich:petich-core:0.2.0")
implementation("io.github.youndie.petich:petich-ktor:0.2.0")
implementation("io.github.youndie.petich:petich-postgres:0.2.0")
}Releases are on Maven Central. Snapshots keep going to
https://reposilite.kotlin.website/snapshots as <version>.<build> — add that repository beside
mavenCentral() to take one.
On Kotlin/Native the coordinates are the same; take petich-sqlx4k-postgres instead of
petich-postgres, and bring your own sqlx4k driver:
dependencies {
implementation("io.github.youndie.petich:petich-core:0.2.0")
implementation("io.github.youndie.petich:petich-sqlx4k-postgres:0.2.0")
implementation("io.github.smyrgeorge:sqlx4k-postgres:1.13.1") // the driver is yours
}petich-postgres deliberately ships no driver and no connection pool: it works with an Exposed
Database handed to it and does not know which DBMS sits underneath. Choosing a driver is the
application's decision. It ships no DDL either — the tables describe themselves, indexes included,
so MigrationUtils and the Exposed Gradle plugin generate a schema that matches what the queries
actually filter on.
A saga step is an interceptor: what to do, and how to undo it.
class ReserveStockInterceptor(private val stock: StockRepository) : PetichInterceptor<OrderPayload> {
override val phase = PetichPhase.EXECUTION
override val priority = 10
override fun supports(payload: PetichPayload) = payload is OrderPayload
override suspend fun intercept(petich: Petich, payload: OrderPayload): InterceptorResult {
stock.reserve(payload.sku, payload.quantity)
return InterceptorResult.Proceed()
}
override suspend fun compensate(petich: Petich, payload: OrderPayload) {
stock.release(payload.sku, payload.quantity)
}
}A step that needs confirmation returns Suspend — the saga stops and waits for a separate resume
call:
return InterceptorResult.Suspend(requiredAction = "CONFIRM", ttl = 5.minutes)ttl is this particular step's deadline. If it passes, the sweeper rolls the saga back exactly as a
refusal would: typing a one-time code and approving a long-running request live on different time
scales, and the step knows that, not the engine.
petich-outbox-core provides the mechanism; the transport
(a queue, a webhook, a push) is implemented by the application;petich-idempotency catches a different case: the same
key with different request parameters;One saga of six interceptors is about 17 database writes, 11 of them into the saga table itself:
1 INSERT + one UPDATE per interceptor + 1 final, plus the suspend/resume machinery. A saga of four
interceptors comes to 9 writes. The numbers were taken through pg_stat_user_tables and do not
depend on the hardware.
This is the price of recoverability: state is written at every step boundary precisely so that a process dying between steps never leaves a saga in an unknown position.
PetichEngineMetrics provides optional counters: saga passes, version conflicts, state-write
retries, compensations, waits on the client, and outbox events dropped. A no-op by default, costing
nothing.
Most exist for a question that cannot be answered from outside: why did throughput drop. From outside you see only latency, while a slowdown that looks identical has at least three distinct causes, each cured differently.
onDroppedEvents is the exception, and answers a question nobody thinks to ask. When the repository
is not outbox-aware the events are thrown away, the saga completes, and its state is correct — every
assertion anyone naturally writes about that run passes, and only the consumer at the far end of the
event never runs. Nothing else in the system is different, which is why a counter is the only thing
that can say it happened. A flat non-zero line here is a plain PetichRepository that reached a
place needing an outbox-aware one; requireOutbox refuses that at construction instead.
Read the counters in the right order. Optimistic retries are the contention signal — zero of them means sagas are not fighting over rows, whatever else is slow. Saga passes per operation is NOT that signal: a saga that suspends for a confirmation goes through the engine at least twice with no contention at all, so the figure sits comfortably above one in a workload where nothing collides.
./gradlew buildOne JVM floor for every module at once — not tidiness but a Gradle requirement: a module built
below the floor cannot depend on one advertising it, so it is all of them or none. The number lives
in gradle.properties as sborka.jvmFloor, and nowhere else: the shared conventions read it there,
tools/jvm-floor-audit.py compares the published bytecode against the same line, and no build script
spells it out.
Java 21 is a consumer's floor too. Every published variant declares it as
org.gradle.jvm.version, so a project on anything older is refused at resolution, by name, before
it compiles rather than at class loading. It was briefly 25 — not because anything here needs 25,
but because that was the JDK the build ran on, which is the accident a named floor exists to
prevent.
MIT.
a distributed saga engine for Kotlin — a multi-step operation is described as a chain of interceptors; the engine walks it through phases and, when any step fails, undoes exactly what had already happened
🔁 one interceptor → one step forward and one step back
Built around a single question: what is left in the system if you die halfway.
Why it is built this way, and what is being worked on: docs/ and the
backlog.
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 some are already
irreversible by then. An ordinary try/catch does not help — what needs undoing is not a database
transaction but actions already performed, in reverse order, and only those that really happened.
The engine takes on exactly that:
ENRICHMENT → VALIDATION → AUTHORIZATION → EXECUTION → POST_PROCESSING,
with steps inside a phase ordered by priority;compensate() on steps N−1 … 1, in reverse;petich-postgres is one) the intent
to emit an event is written in the SAME transaction as the state change, which makes "the work
happened but the notification never went out" structurally impossible. A repository without that
support still works; the engine falls back to a plain update and drops the events. That fallback
is now countable and refusable: PetichEngineMetrics.onDroppedEvents fires on every event lost
this way, and PetichEngineConfig(requireOutbox = true) refuses to build an engine whose
repository cannot store them at all. Both are off by default, so a deliberately outbox-free
application changes nothing; anything wiring the outbox to a broker wants the second one, because
the drop is otherwise invisible — the saga completes and its state is correct.| module | what for | targets | depends on |
|---|---|---|---|
petich-core |
the engine: sagas, interceptors, phases, compensation, suspend/resume, TTL | jvm, linuxX64 | — |
petich-ktor |
REST endpoints for creating and resuming a saga | jvm, linuxX64 | petich-core |
petich-postgres |
storage on Exposed | jvm only — Exposed over JDBC, and JDBC is a JVM interface rather than a protocol | core, outbox, idempotency, scheduler |
petich-outbox-core |
at-least-once event delivery with backoff and dead lettering | jvm, linuxX64 | — |
petich-idempotency |
protection against a key reused with a DIFFERENT request | jvm, linuxX64 | — |
petich-scheduler |
a saga on a schedule, starting with no HTTP initiator | jvm, linuxX64 | — |
petich-chronik |
a fired chronik timer resumes a suspended saga | jvm, linuxX64 — needs chronik 0.2.0 or newer | petich-core |
petich-conformance |
the rules a storage implementation has to satisfy, as cases you can run against yours | jvm, linuxX64 | core, outbox, idempotency, scheduler |
petich-sqlx4k-postgres |
the same storage contracts over sqlx4k, for a service with no JVM; brings no driver | jvm, linuxX64 | core, outbox, idempotency, scheduler |
A Kotlin/Native service can take the engine, its HTTP surface, the three independent modules and a
store: petich-sqlx4k-postgres implements the same four contracts over sqlx4k and is accepted by
the corpus in petich-conformance on both targets. petich-postgres stays where it is — Exposed
over JDBC — and both write the same columns, so a service can move one process at a time. See
docs/.
Three modules deliberately do not depend on the core. petich-outbox-core knows only about a row —
"id/type/payload, deliver at least once"; petich-scheduler only about "it is time" and "here is the
payload"; petich-idempotency only about "this key already arrived with a different fingerprint".
Each is usable on its own, and that is not an accident but the condition under which they do not turn
into part of somebody's feature.
repositories {
mavenCentral()
}
dependencies {
implementation("io.github.youndie.petich:petich-core:0.2.0")
implementation("io.github.youndie.petich:petich-ktor:0.2.0")
implementation("io.github.youndie.petich:petich-postgres:0.2.0")
}Releases are on Maven Central. Snapshots keep going to
https://reposilite.kotlin.website/snapshots as <version>.<build> — add that repository beside
mavenCentral() to take one.
On Kotlin/Native the coordinates are the same; take petich-sqlx4k-postgres instead of
petich-postgres, and bring your own sqlx4k driver:
dependencies {
implementation("io.github.youndie.petich:petich-core:0.2.0")
implementation("io.github.youndie.petich:petich-sqlx4k-postgres:0.2.0")
implementation("io.github.smyrgeorge:sqlx4k-postgres:1.13.1") // the driver is yours
}petich-postgres deliberately ships no driver and no connection pool: it works with an Exposed
Database handed to it and does not know which DBMS sits underneath. Choosing a driver is the
application's decision. It ships no DDL either — the tables describe themselves, indexes included,
so MigrationUtils and the Exposed Gradle plugin generate a schema that matches what the queries
actually filter on.
A saga step is an interceptor: what to do, and how to undo it.
class ReserveStockInterceptor(private val stock: StockRepository) : PetichInterceptor<OrderPayload> {
override val phase = PetichPhase.EXECUTION
override val priority = 10
override fun supports(payload: PetichPayload) = payload is OrderPayload
override suspend fun intercept(petich: Petich, payload: OrderPayload): InterceptorResult {
stock.reserve(payload.sku, payload.quantity)
return InterceptorResult.Proceed()
}
override suspend fun compensate(petich: Petich, payload: OrderPayload) {
stock.release(payload.sku, payload.quantity)
}
}A step that needs confirmation returns Suspend — the saga stops and waits for a separate resume
call:
return InterceptorResult.Suspend(requiredAction = "CONFIRM", ttl = 5.minutes)ttl is this particular step's deadline. If it passes, the sweeper rolls the saga back exactly as a
refusal would: typing a one-time code and approving a long-running request live on different time
scales, and the step knows that, not the engine.
petich-outbox-core provides the mechanism; the transport
(a queue, a webhook, a push) is implemented by the application;petich-idempotency catches a different case: the same
key with different request parameters;One saga of six interceptors is about 17 database writes, 11 of them into the saga table itself:
1 INSERT + one UPDATE per interceptor + 1 final, plus the suspend/resume machinery. A saga of four
interceptors comes to 9 writes. The numbers were taken through pg_stat_user_tables and do not
depend on the hardware.
This is the price of recoverability: state is written at every step boundary precisely so that a process dying between steps never leaves a saga in an unknown position.
PetichEngineMetrics provides optional counters: saga passes, version conflicts, state-write
retries, compensations, waits on the client, and outbox events dropped. A no-op by default, costing
nothing.
Most exist for a question that cannot be answered from outside: why did throughput drop. From outside you see only latency, while a slowdown that looks identical has at least three distinct causes, each cured differently.
onDroppedEvents is the exception, and answers a question nobody thinks to ask. When the repository
is not outbox-aware the events are thrown away, the saga completes, and its state is correct — every
assertion anyone naturally writes about that run passes, and only the consumer at the far end of the
event never runs. Nothing else in the system is different, which is why a counter is the only thing
that can say it happened. A flat non-zero line here is a plain PetichRepository that reached a
place needing an outbox-aware one; requireOutbox refuses that at construction instead.
Read the counters in the right order. Optimistic retries are the contention signal — zero of them means sagas are not fighting over rows, whatever else is slow. Saga passes per operation is NOT that signal: a saga that suspends for a confirmation goes through the engine at least twice with no contention at all, so the figure sits comfortably above one in a workload where nothing collides.
./gradlew buildOne JVM floor for every module at once — not tidiness but a Gradle requirement: a module built
below the floor cannot depend on one advertising it, so it is all of them or none. The number lives
in gradle.properties as sborka.jvmFloor, and nowhere else: the shared conventions read it there,
tools/jvm-floor-audit.py compares the published bytecode against the same line, and no build script
spells it out.
Java 21 is a consumer's floor too. Every published variant declares it as
org.gradle.jvm.version, so a project on anything older is refused at resolution, by name, before
it compiles rather than at class loading. It was briefly 25 — not because anything here needs 25,
but because that was the JDK the build ran on, which is the accident a named floor exists to
prevent.
MIT.