Skip to main content
By default Yukta keeps its definitions, sessions, run history and node outputs in memory or in files on one server. persistence-postgres is the optional module that moves them into PostgreSQL, adds a leader lease so two servers can share the database, and holds the small state of KV_STORE and SESSION_MEMORY nodes. It has no engine logic: it implements storage interfaces that core and plugin-api define. This page is for thinking about it: open a component, play a scenario, then challenge the weak spots.
Open risks, bugs and improvement ideas for this module are on the internal pages (sign-in required): Risks, Bugs and Improvements.

Primer

A Yukta server forgets everything when it restarts unless it writes state somewhere: the workflow designs, which sessions exist, which runs started and ended, what each node produced. Files work for one server. They do not work when two servers should share one state, and they are awkward to query. This module writes the same state into PostgreSQL instead, and adds one more thing files cannot do easily: a lease that tells several servers which one is in charge.
Think of an office that used to keep its records in a filing cabinet on one desk. This module moves the cabinet into a shared records room: any clerk can use it, and a duty roster on the door says which clerk is on duty. The clerks still do their jobs the same way; only where the paper lives has changed.

Where persistence-postgres sits

The module depends on core and plugin-api. Nothing depends on it except the application (boot), which includes it only when built with -Pyukta.persistence=postgres.

The vocabulary in eight cards

One run, from its start to a resume after a crash

A workflow run is logged, each node leaves its output, the server crashes, and a restart finds the run and resumes it. Click a step to see what happens, why, and what can go wrong.
1

Run starts

What happens. Core appends RUN_STARTED with the start payload. append only queues the event; the single writer thread inserts it, together with other waiting events, into execution_log.Why it works this way. The start is logged before the run does any work, so a crash right after still leaves a trace that the run existed and what its input was.Can go wrong. If the queue of 10,000 events is full the event is dropped and only an error line says so; the run then has no start in the log and cannot be resumed.
2

Node output

What happens. Each message a node produces is inserted as a row in node_checkpoint (JSONB). A failed insert marks the node incomplete in node_checkpoint_state.Why it works this way. A later restart from this node can replay the recorded messages instead of running the upstream nodes again.Can go wrong. Messages come back as plain JSON values (maps, lists, numbers, text), not the Java types the node produced.
3

Node ends

What happens. After the last message core calls markComplete: a second statement sets done for the node. Only a node with that stamp is skipped by a resume.Why it works this way. The stamp separates a node that finished from one that was cut off while writing, which is what makes a resume safe.Can go wrong. The stamp is a separate statement from the last message. A crash between them makes the node run again, so its side effects must tolerate that (at-least-once).
4

Run ends

What happens. Core appends RUN_FINISHED (or aborted). Every 10,000 appended events the writer also trims the table to the newest 500 runs.Why it works this way. Trimming keeps the table from growing without limit, and it never removes a run that has a start and no end.Can go wrong. The trim groups the whole table by execution id each time; on a large log it is a heavy statement on the single writer thread.
5

Server crashes

What happens. The JVM stops. Events still in the in-memory queue are gone; everything already inserted is in PostgreSQL.Why it works this way. PostgreSQL commits each statement on its own, so what was written is durable without any extra step.Can go wrong. Up to 10,000 queued events can be lost with the process. A run whose end event was among them looks interrupted.
6

Restart recovery

What happens. On start (or when a standby takes over) core reads the whole log, marks runs without an end ABORTED, and rewrites the log without the oldest runs with replaceAll: delete every row, then insert the kept events one by one.Why it works this way. The rewrite keeps the log bounded and records the abort of each interrupted run.Can go wrong. The rewrite is not in a transaction. A crash after the delete and before the inserts finish loses the log, and with it the means to resume anything.
7

Resume

What happens. ExecutionResumer reads the log for the run’s payload, asks isFinished for each node, replays the recorded output of finished nodes with getAll and runs the rest again.Why it works this way. Finished work is not repeated and the run continues from where it was cut off.Can go wrong. isFinished is a blocking call per node (up to 30 s each if the database is slow), and a timeout counts as unfinished, so that node runs again.

A run’s state in plain words

1

Designed

a workflow definition is saved; the identical definition twice adds no version.
2

Started

the log records the start and the input before any node runs.
3

Producing

each node leaves its output rows; a stamp is set when the node ends.
4

Ended or cut off

the log records the end; if the server dies first, the run has no end.
5

Found again

on the next start the log is read and the run is marked aborted, or resumed.
6

Trimmed

old runs are deleted once there are more than 500; runs without an end are kept.

Components

Auto-configuration

which stores exist Decides, from properties, which store beans the application gets: PostgreSQL ones, or none (then the in-memory and file defaults in core stay).
  • PostgresPersistenceAutoConfiguration is registered through AutoConfiguration.imports. Its three always-on beans (workflow definitions, session config, execution log) exist when yukta.session.store-type=postgres.
  • Four more beans are switched on one by one: yukta.restart.store-type=postgres (node checkpoints), yukta.ha.mode=postgres (leader lease), yukta.kv.store-type=postgres (KV_STORE state) and yukta.session-memory.store-type=postgres (chat history). So a deployment can be half on PostgreSQL.
  • The log store takes yukta.execution-log.retained-runs (500) and compact-after (10,000); the lease takes yukta.ha.instance-id (random if unset) and yukta.ha.lease (15 s); the history store takes an optional yukta.session-memory.ttl.
  • The module is only on the classpath when the application is built with -Pyukta.persistence=postgres, so the choice is made twice: at build time and by properties.
  • Guarantees. With the matching property set, the matching bean exists; without it, core’s default store is used.
  • State. None. Beans are created once at start.
  • Ceiling. Nothing checks that the properties agree: the session store can be PostgreSQL while checkpoints stay in memory, which is a half-durable deployment.
  • On failure. A missing spring.r2dbc.url fails the application at start. A wrong store-type value silently selects the default store.
Settings: yukta.session.store-type, yukta.restart.store-type, yukta.ha.mode, yukta.kv.store-type, yukta.session-memory.store-type, yukta.session-memory.ttl

Connections & drivers

R2DBC + JDBC Provides the two connections the module needs: R2DBC for all run-time statements and JDBC for Flyway.
  • At run time every store uses Spring’s DatabaseClient over R2DBC (spring.r2dbc.url). Flyway has no R2DBC support, so the schema is migrated over JDBC from spring.flyway.url with spring.flyway.user and spring.flyway.password.
  • The driver jars are runtimeOnly: the R2DBC PostgreSQL driver, the PostgreSQL JDBC driver and the Flyway PostgreSQL module.
  • The R2DBC driver pulls in scram-client 3.2, which has a high-severity advisory; the build pins scram-client and scram-common to 3.3 with a constraint.
  • Several stores wait for the database on the calling thread (see the Blocking calls risk): they convert a Mono to a future and wait with a timeout (10 s for history and lease, 30 s for checkpoints and the log).
  • Guarantees. Every statement has a timeout where it blocks; a timeout is logged and turned into a safe default.
  • State. The connection pool belongs to Spring Boot’s R2DBC auto-configuration, not to this module.
  • Ceiling. Pool size and R2DBC pool metrics are not set or exposed here.
  • On failure. An unreachable database makes writes fail (logged) and reads return empty or “unfinished”; the application keeps running.
Settings: spring.r2dbc.url, spring.flyway.url, spring.flyway.user, spring.flyway.password

Schema migrations

V1 to V6 Creates and evolves the nine tables. Each migration is a numbered SQL file that runs once per database.
  • V1 creates workflow_definition, workflow_definition_revision, session_config and execution_log (with an index on execution id). V2 adds the version note. V3 adds node_checkpoint and node_checkpoint_state. V4 adds leader_lease. V5 adds kv_entry. V6 adds session_history.
  • Flyway runs on every server at start. It locks the schema history table, so two servers starting together do not both migrate.
  • All state is stored as JSONB documents plus a few key columns, so most changes to the Java records need no migration; a migration is needed only for a new table or column.
  • There are no down scripts. Nothing is partitioned, and the only indexes besides the primary keys are on execution_log(execution_id) and node_checkpoint(execution_id, node_id, seq).
  • Guarantees. A migration that ran is never run again; the schema history records it.
  • State. The flyway_schema_history table in the same database.
  • Ceiling. Forward-only: a bad migration is fixed by another migration or by hand.
  • On failure. A failing migration stops the application at start, before any store is used.
Settings: spring.flyway.*

KV and chat history

two small stores Keep the state of KV_STORE nodes and the chat history of SESSION_MEMORY nodes in PostgreSQL, so both survive a restart and are shared by servers.
  • R2dbcKeyValueStore (table kv_entry, key + JSONB value) implements the plugin-api KeyValueStore. put and incr are single statements: a counter is incremented in the database, so concurrent increments do not lose updates. incr on a value that is not a whole number turns the database error into an IllegalArgumentException.
  • The size cap (maxEntries) is a condition inside the insert: it counts all rows, and refuses a new key when the count has reached the cap. The count and the insert are one statement but not serialised against other inserts, so two concurrent new keys can both pass.
  • R2dbcSessionHistoryStore (table session_history, session id + JSONB turns) implements SessionHistoryStore. Its calls block the calling thread, up to 10 s; a failure is logged and the chat goes on from memory.
  • With yukta.session-memory.ttl, every save also deletes the sessions whose updated_at is older than the ttl. A load does not check the ttl.
  • Guarantees. Counters are exact; a history save never fails the chat.
  • State. Tables kv_entry and session_history.
  • Ceiling. The ttl delete scans the table on every save (no index on updated_at); the KV cap counts all rows on every new key.
  • On failure. A database failure on the history store means the history lives only in memory until the next successful save.
Settings: yukta.kv.store-type, yukta.session-memory.store-type, yukta.session-memory.ttl

Definitions & sessions

workflow definitions, session config Stores the workflow definitions of each session with their newest 20 versions, and the session’s own settings.
  • R2dbcWorkflowDefinitionStore.save is one statement: it writes a new row in workflow_definition_revision unless the stored definition is identical (compared as JSONB), and upserts the current row. A second statement then deletes all but the newest 20 revisions of that workflow. The revision id comes from the time and the definition, and is ordered as text.
  • saveNote sets the note on one version (an empty note is stored as null) and fails with Not Found if the version does not exist. findRevision and revisions back the web diff and rollback endpoints.
  • R2dbcSessionConfigStore keeps name, description, project path, initiator, initiated time and tags in session_config. Initiator, description, initiated time and tags are set only while still empty. The execution timeout and the log directories are not stored: they come from SessionConfigProperties for every session.
  • Applying a session config writes the row, then saves each workflow one after the other. The store has no way to delete a session row, and the interface has no delete either.
  • getAllConfigs runs about seven queries for one session. getSessionIds lists sessions that have a config row, so definitions without a row are not listed.
  • Guarantees. Saving an identical definition adds no version; at most 20 versions per workflow are kept.
  • State. Tables workflow_definition, workflow_definition_revision and session_config.
  • Ceiling. Removing a workflow or a session’s workflows is two statements; applying a session is several. None of it is in a transaction.
  • On failure. A failure between statements leaves a partly applied session (the config row replaced, some workflows saved) or a workflow with versions but no current row.
Settings: yukta.session.store-type

Execution log

run history, resume source Records the start and end of runs, node ends, control commands and idempotency keys, so a restart can mark interrupted runs ABORTED and resume them.
  • append puts an event in a queue (10,000 events) and asks one writer thread to drain it. The writer inserts up to 100 events per statement; if a batch fails it writes the events one by one and logs the ones that still fail. When the queue is full the event is dropped and an error is logged.
  • Reads (readAll) and rewrites (replaceAll) run on the same writer thread, after the pending events, so they see everything appended before them. readAll loads every row into memory and skips rows it cannot parse.
  • replaceAll is used by startup and takeover recovery to rewrite the log without the oldest runs. It deletes every row and then inserts the kept events one at a time, each with its own statement and a 30 s wait, with no transaction.
  • After every compact-after (10,000) appended events the writer trims the table to the newest retained-runs (500) runs by their last event, never dropping a run that has a start but no end, and keeps the newest 500 scheduler entries (execution id scheduler).
  • If an event’s payload cannot be written as JSON, the event is stored without the payload and a warning says that the run cannot be resumed.
  • Guarantees. Events of one run keep their order (the sequence column); a run without an end is never trimmed.
  • State. Table execution_log: a sequence number, the execution id and the event as JSONB. In memory: the queue and a counter since the last compaction.
  • Ceiling. One writer thread, one statement at a time. Compaction groups the whole table by execution id every 10,000 events.
  • On failure. A full queue or a failing insert loses events silently as far as the run is concerned; a crash inside replaceAll loses the log.
Settings: yukta.execution-log.retained-runs, yukta.execution-log.compact-after

Node checkpoints

outputs and end markers Stores what each node of a run produced and whether it ended normally, so a run can restart from a node or resume after a crash.
  • save inserts one row per message in node_checkpoint (JSONB). If the insert fails the node is marked incomplete in node_checkpoint_state, so a restart that needs this node is refused instead of replaying part of it.
  • markComplete sets the done flag in a second table, after the last message; if that fails the node is marked incomplete instead and a resume will run it again. isFinished is true only when done is set and incomplete is not.
  • isComplete and isFinished are plain (blocking) methods in the interface, so they wait for the database up to 30 s; a timeout is treated as unfinished, which is the safe side for a resume.
  • get reads all rows of the node and takes the last one. getAll streams the rows in order. clear deletes the rows and the state of a run, and lowers the byte counter.
  • The byte counter (bytesHeld, shown as a core meter) counts characters on save and bytes on clear, only in this JVM.
  • Guarantees. A node counts as finished only when its end marker is durable; a failed save makes the node incomplete.
  • State. Tables node_checkpoint and node_checkpoint_state; the counter is in memory.
  • Ceiling. The end marker is a separate statement from the last message, so there is a short window between them.
  • On failure. A database failure marks nodes incomplete, which makes a restart from them impossible but never replays half an output.
Settings: yukta.restart.store-type

Leader lease

one active server Lets several servers share the database while only one admits runs, and lets another take over when the leader stops.
  • One row in leader_lease holds the name, the holder, a fencing number and an expiry. Each server runs one statement every third of a lease (5 s by default): it inserts the row or, if it is the holder or the lease has expired, takes it and renews the expiry. The fencing number grows by one when the holder changes.
  • The expiry uses the database clock. A server counts itself the leader only while the time since it sent its last successful renewal is shorter than the lease, measured with a local monotonic clock, so a server that cannot reach the database stops being leader before another can take over.
  • When a server becomes leader it runs the registered takeover actions on another thread (core uses this for recovery and auto-resume). A server that stops releases the lease by setting its expiry in the past, waiting up to 10 s.
  • fencingToken() exposes the number, but nothing in this module or in core uses it to reject a write from an old leader. The gauge yukta.leader is 1 on the leader.
  • Guarantees. Two servers never both believe they hold a lease that has not expired, as long as the local clock does not run slow relative to the database clock.
  • State. The row in leader_lease; in memory the validity time, the fencing number and the takeover list.
  • Ceiling. One tiny statement per 5 s per server; no contention beyond that.
  • On failure. A leader paused for longer than the lease (a long stop-the-world pause) can still finish a write after another server has taken over.
Settings: yukta.ha.mode, yukta.ha.lease, yukta.ha.instance-id

Tests & build

Testcontainers Shows each store works against a real PostgreSQL 18, and keeps the module at full coverage.
  • Every store has a test class that runs against a PostgreSQL container started by Testcontainers (postgres:18-alpine); the base class migrates the schema with Flyway and empties all nine tables before each test.
  • The lease tests cover a graceful hand-over, a crashed holder replaced after its lease, an unreachable database ending leadership after the holder’s own lease, and a failing takeover action not stopping others. The boot module has a Postgres acceptance test, run with -Pyukta.persistence=postgres.
  • The build applies the library conventions, so the module has the same Checkstyle, PMD, SpotBugs and 100% per-class coverage gates as the others.
  • Guarantees. Behaviour that needs a database is tested against a real one, not a mock.
  • State. A container started once per test JVM and stopped at exit.
  • Ceiling. The tests need Docker. They start from an empty database, so no test upgrades an older schema that already holds data.
  • On failure. Without Docker the container is not started and the tests fail in the base class.

Scenarios

A normal run

One run from the saved design to the trimmed log, in the order the statements run.
1

The design is saved

An operator applies a session. The session row is upserted, then each workflow is saved in its own statement: a new version row unless the definition is identical, the current row replaced, and all but the newest 20 versions pruned in a second statement.
2

The run is logged

Core appends RUN_STARTED. The event waits in the queue, and the writer thread inserts it with its neighbours in one batched statement.
3

Nodes leave output

Each message is inserted into node_checkpoint. When the node ends, a second statement stamps it done.
4

The run ends

Core appends the end event. The run’s outputs stay for a restart until the run falls out of the archive of retained executions (10 by default); then core clears them with two deletes, and the call waits up to 30 s.
5

The table is trimmed

After 10,000 appended events the writer deletes all but the newest 500 runs. A run that started and has no end is never deleted.

The server crashes mid-run

What is on disk when the process dies, and what the next start does with it.
1

The process dies

Events waiting in the in-memory queue are lost with the process. Everything already inserted is durable.
2

Node state on disk

Finished nodes have their output rows and a done stamp. The node that was running has some rows and no stamp.
3

The next start

Flyway checks the schema, the stores are created, and recovery reads the log: a run with a start and no end is marked ABORTED.
4

Resume (optional)

With auto-resume, each aborted run is resumed once: finished nodes replay their recorded output, the rest run again. Each isFinished is a blocking query.

Failover to a standby

How a standby takes the lease, and what the lease does not protect against.
1

Two servers, one lease

Both run the renewal statement every 5 s. One has the row, the other’s statement changes nothing and it counts itself a standby.
2

The leader stops

A graceful stop sets the expiry in the past; a crash leaves the lease to run out (15 s). The standby’s next renewal then takes the row and raises the fencing number.
3

Takeover actions run

The new leader runs the registered actions on another thread: startup recovery (mark the old leader’s runs ABORTED, rewrite the log) and, if set, auto-resume.
4

A paused old leader

If the old leader was only paused (a long garbage collection, a frozen VM), it may wake and finish a write it had already started. Nothing in the database refuses a write carrying an old fencing number.

Startup recovery rewrites the log

The startup rewrite of the execution log, and why it is the most fragile statement sequence in the module.
1

Read everything

Recovery calls readAll: every row of the log is loaded into memory, parsed, and rows that fail to parse are skipped with a warning.
2

Decide what to keep

It keeps the newest 500 runs, marks the ones without an end ABORTED, and clears the checkpoints of the runs it drops.
3

Replace the log

replaceAll deletes all rows and inserts the kept events one by one, each waiting up to 30 s. There is no transaction around the delete and the inserts.
4

If it is cut short

A crash, a lost connection or a leader change between the delete and the last insert leaves a partial log. The runs that could be resumed are gone from it.

Counters and chat survive a restart

The small stores for counters and chat: what is atomic, what is capped, what is cleaned.
1

A counter is incremented

incr adds to the stored whole number in one statement, so two servers incrementing together lose nothing. If a value is not a whole number the database error becomes an IllegalArgumentException.
2

The cap is checked

A new key is accepted only if the table has fewer rows than maxEntries. Two new keys arriving together can both pass the check.
3

A chat turn is saved

The history of the session is written as one JSONB cell. With a ttl, the same call then deletes expired sessions by scanning the table.
4

After a restart

The plugin loads the history again from the table; a failure to read is logged and the chat starts empty.

Capabilities

What this module does, how, what it guarantees and where the guarantee stops.

Definitions

Sessions

Run history

Node state

High availability

Plugin state

Schema

What survives a crash, and what is only best effort

Each store has its own switch, so the table assumes the matching switch is set. PostgreSQL commits every statement on its own; nothing here uses a multi-statement transaction.

Enforced by the module

Each rule is applied in a statement or in the store’s code and is covered by a test.

What survives a restart or a crash

Compare with the in-memory and file defaults in core.

Limits and defaults

Reference

Tables, writers and cleaners

Nine tables from six migrations. ‘Cleaned by’ says what removes rows; ‘nothing’ means no code in this module or in core does.

Settings that choose a store

Set the build flag once, then one property per kind of data.

Do and don’t for operators

Short rules that follow from the guarantees.

Architecture decisions

Context. The engine is reactive; Flyway has no R2DBC support.Decision. Stores use Spring’s DatabaseClient over R2DBC. Flyway migrates over a separate JDBC connection at start.Consequences. Non-blocking statements and versioned schema; two connection settings to keep in step; native-image metadata needed for both drivers.Alternatives considered. JDBC everywhere with a thread pool: simpler tooling, but blocks threads. Hibernate Reactive: heavier.Revisit when. a second database is needed, or Flyway gains R2DBC support.
Context. Definitions, events, messages and chat turns are Java records that change often.Decision. Store each as a JSONB cell next to the columns used for lookups.Consequences. Few migrations and easy storage of whatever the record holds; no column-level queries or constraints, and old rows carry the old shape.Alternatives considered. A normalised schema: queryable and constrained, but a migration for every record change.Revisit when. the record shapes settle and reporting queries are needed.
Context. Deployments need different mixes: files for one store, PostgreSQL for another.Decision. Each store is a bean guarded by its own property; the module is added at build time.Consequences. Flexible and incremental; nothing checks the combination, so half-durable setups are possible.Alternatives considered. One ‘postgres’ switch for all: simpler, but no gradual adoption.Revisit when. support requests show people misconfiguring the mix.
Context. Two servers should share a database and only one should admit runs.Decision. One row, renewed by the holder, taken over after expiry; the number grows with every change.Consequences. Simple and uses the database already there; split-brain protection depends on the fencing number, which nothing checks yet.Alternatives considered. Advisory locks (tied to a session, lost silently on reconnect), ZooKeeper or etcd (another system).Revisit when. a stale leader is observed writing, or the lease needs sub-second failover.
Context. Events must keep their order and must not slow down the engine.Decision. A queue and one thread that inserts in batches and also does reads, rewrites and trims.Consequences. Ordered, cheap and simple to reason about; one slow statement (a trim, a rewrite) delays all appends and can overflow the queue.Alternatives considered. Insert on the calling thread: simple but couples engine speed to the database. A message broker: durable but another system.Revisit when. the queue overflows in normal operation.
Context. NodeCheckpointStore.isFinished, SessionHistoryStore and the log store are plain methods in their interfaces.Decision. Implement them by waiting on the reactive statement with a timeout, and fall back to a safe value.Consequences. Works with callers that are not reactive; occupies the calling thread and hides a slow database behind ‘unfinished’ or ‘empty’.Alternatives considered. Make the interfaces reactive: cleaner, a change in core and plugin-api.Revisit when. the waits show up in latency, or a caller runs on an event-loop thread.
Context. A restart needs a node’s whole output and nodes can produce large messages.Decision. Insert each message as a JSONB row; PostgreSQL stores large values out of line.Consequences. No cap and no special handling for large messages; resume loads the output into memory.Alternatives considered. Files (the other store) with their own caps; an object store with references for large payloads.Revisit when. payload sizes require a claim-check reference.

Glossary