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.Where persistence-postgres sits
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.Run starts
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.Node output
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.Node ends
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).Run ends
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.Server crashes
Restart recovery
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.Resume
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
Designed
Started
Producing
Ended or cut off
Found again
Trimmed
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).-
PostgresPersistenceAutoConfigurationis registered throughAutoConfiguration.imports. Its three always-on beans (workflow definitions, session config, execution log) exist whenyukta.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) andyukta.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) andcompact-after(10,000); the lease takesyukta.ha.instance-id(random if unset) andyukta.ha.lease(15 s); the history store takes an optionalyukta.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.urlfails the application at start. A wrongstore-typevalue silently selects the default store.
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
DatabaseClientover R2DBC (spring.r2dbc.url). Flyway has no R2DBC support, so the schema is migrated over JDBC fromspring.flyway.urlwithspring.flyway.userandspring.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.
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_configandexecution_log(with an index on execution id). V2 adds the versionnote. V3 addsnode_checkpointandnode_checkpoint_state. V4 addsleader_lease. V5 addskv_entry. V6 addssession_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)andnode_checkpoint(execution_id, node_id, seq). - Guarantees. A migration that ran is never run again; the schema history records it.
-
State. The
flyway_schema_historytable 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.
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(tablekv_entry, key + JSONB value) implements the plugin-apiKeyValueStore.putandincrare single statements: a counter is incremented in the database, so concurrent increments do not lose updates.incron a value that is not a whole number turns the database error into anIllegalArgumentException. -
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(tablesession_history, session id + JSONB turns) implementsSessionHistoryStore. 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 whoseupdated_atis 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_entryandsession_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.
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.saveis one statement: it writes a new row inworkflow_definition_revisionunless 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. -
saveNotesets the note on one version (an empty note is stored as null) and fails with Not Found if the version does not exist.findRevisionandrevisionsback the web diff and rollback endpoints. -
R2dbcSessionConfigStorekeeps name, description, project path, initiator, initiated time and tags insession_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 fromSessionConfigPropertiesfor 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.
-
getAllConfigsruns about seven queries for one session.getSessionIdslists 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_revisionandsession_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.
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.-
appendputs 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.readAllloads every row into memory and skips rows it cannot parse. -
replaceAllis 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 newestretained-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 idscheduler). - 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
replaceAllloses the log.
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.-
saveinserts one row per message innode_checkpoint(JSONB). If the insert fails the node is marked incomplete innode_checkpoint_state, so a restart that needs this node is refused instead of replaying part of it. -
markCompletesets 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.isFinishedis true only when done is set and incomplete is not. -
isCompleteandisFinishedare 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. -
getreads all rows of the node and takes the last one.getAllstreams the rows in order.cleardeletes 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_checkpointandnode_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.
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_leaseholds 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 gaugeyukta.leaderis 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.
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.The design is saved
The run is logged
RUN_STARTED. The event waits in the queue, and the writer thread inserts it with its neighbours in one batched statement.Nodes leave output
node_checkpoint. When the node ends, a second statement stamps it done.The run ends
The table is trimmed
The server crashes mid-run
What is on disk when the process dies, and what the next start does with it.The process dies
Node state on disk
The next start
Resume (optional)
isFinished is a blocking query.Failover to a standby
How a standby takes the lease, and what the lease does not protect against.Two servers, one lease
The leader stops
Takeover actions run
A paused old leader
Startup recovery rewrites the log
The startup rewrite of the execution log, and why it is the most fragile statement sequence in the module.Read everything
readAll: every row of the log is loaded into memory, parsed, and rows that fail to parse are skipped with a warning.Decide what to keep
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.If it is cut short
Counters and chat survive a restart
The small stores for counters and chat: what is atomic, what is capped, what is cleaned.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.The cap is checked
maxEntries. Two new keys arriving together can both pass the check.A chat turn is saved
After a restart
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
R2DBC at run time, Flyway over JDBC for the schema
R2DBC at run time, Flyway over JDBC for the schema
JSONB documents with a few key columns
JSONB documents with a few key columns
One module, one switch per store
One module, one switch per store
A lease row with a fencing number for leader election
A lease row with a fencing number for leader election
One writer thread for the execution log
One writer thread for the execution log
Blocking waits behind the blocking interfaces
Blocking waits behind the blocking interfaces
Store checkpoints one row per message, no size cap
Store checkpoints one row per message, no size cap