|
| 1 | +# Data Attribute → Postgres sync (user guide) |
| 2 | + |
| 3 | +This feature lets you automatically mirror an iWF **data attribute** to a single cell (a row + column) |
| 4 | +of an arbitrary, **user-owned** Postgres table: |
| 5 | + |
| 6 | +- **Load on start**: when a workflow starts, the data attribute's initial value is loaded from the |
| 7 | + mapped database column. |
| 8 | +- **Sync on mutation**: whenever the data attribute is mutated inside a state's `execute` method, the |
| 9 | + new value is written back to the same column. |
| 10 | + |
| 11 | +It is entirely an **SDK-side** feature — iWF's server is not involved in your database, and you own the |
| 12 | +database schema, credentials, and connection pooling. |
| 13 | + |
| 14 | +## Mental model |
| 15 | + |
| 16 | +The iWF data attribute is the **source of truth**; the Postgres cell is a **mirror**. |
| 17 | + |
| 18 | +- On start, the mirror seeds the data attribute (load). |
| 19 | +- During execution, the data attribute drives the mirror (sync). |
| 20 | +- iWF persists the data attribute first, then the database is updated. If the database write fails, only |
| 21 | + the (internal) sync step is retried — your `execute` code is **not** re-run. |
| 22 | + |
| 23 | +## Prerequisites |
| 24 | + |
| 25 | +1. A Postgres table you own, with: |
| 26 | + - a **primary key column** (text/uuid/numeric — the PK value is bound as a string parameter), and |
| 27 | + - a **data column** of type `text` or `jsonb` (the SDK stores the data attribute's JSON there). |
| 28 | +2. A configured `javax.sql.DataSource`. You own it — the SDK never stores credentials and never creates |
| 29 | + connections other than via `dataSource.getConnection()`. Use any driver/pool (HikariCP, |
| 30 | + `PGSimpleDataSource`, etc.). |
| 31 | + |
| 32 | +Example table: |
| 33 | + |
| 34 | +```sql |
| 35 | +CREATE TABLE orders ( |
| 36 | + id TEXT PRIMARY KEY, |
| 37 | + status_col TEXT |
| 38 | +); |
| 39 | +INSERT INTO orders (id, status_col) VALUES ('order-123', '"NEW"'); |
| 40 | +``` |
| 41 | + |
| 42 | +## Setup |
| 43 | + |
| 44 | +### 1. Build a DataSource |
| 45 | + |
| 46 | +```java |
| 47 | +import org.postgresql.ds.PGSimpleDataSource; |
| 48 | +import javax.sql.DataSource; |
| 49 | + |
| 50 | +PGSimpleDataSource ds = new PGSimpleDataSource(); |
| 51 | +ds.setUrl("jdbc:postgresql://localhost:5432/mydb"); |
| 52 | +ds.setUser("app"); |
| 53 | +ds.setPassword(System.getenv("DB_PASSWORD")); // keep credentials out of code |
| 54 | +// (in production prefer a pooled DataSource, e.g. HikariCP) |
| 55 | +``` |
| 56 | + |
| 57 | +### 2. Declare the data attribute with a DB-sync mapping |
| 58 | + |
| 59 | +Use the `DataAttributeDef.create(type, key, DbAttributeSync)` overload in your workflow's |
| 60 | +`getPersistenceSchema()`: |
| 61 | + |
| 62 | +```java |
| 63 | +import io.iworkflow.core.persistence.*; |
| 64 | + |
| 65 | +public class OrderWorkflow implements ObjectWorkflow { |
| 66 | + public static final String STATUS = "status"; |
| 67 | + |
| 68 | + @Override |
| 69 | + public List<PersistenceFieldDef> getPersistenceSchema() { |
| 70 | + return Arrays.asList( |
| 71 | + DataAttributeDef.create( |
| 72 | + String.class, |
| 73 | + STATUS, |
| 74 | + DbAttributeSync.of( |
| 75 | + /* dataSource */ MyDataSources.ORDERS, |
| 76 | + /* table */ "orders", // optionally schema-qualified: "public.orders" |
| 77 | + /* pk column */ "id", |
| 78 | + /* locator */ (workflowId, persistence) -> |
| 79 | + new CellLocation(workflowId, "status_col")))); |
| 80 | + } |
| 81 | + |
| 82 | + @Override |
| 83 | + public List<StateDef> getWorkflowStates() { |
| 84 | + return Arrays.asList(StateDef.startingState(new DecideState())); |
| 85 | + } |
| 86 | +} |
| 87 | +``` |
| 88 | + |
| 89 | +**The locator** `(String workflowId, Persistence persistence) -> CellLocation` returns which cell to |
| 90 | +load-from / sync-to: |
| 91 | + |
| 92 | +- `CellLocation.getPkValue()` — the primary-key **value** identifying the row (bound as a parameter). |
| 93 | +- `CellLocation.getColumnName()` — the **data column** name. |
| 94 | + |
| 95 | +The table name and the primary-key column name are fixed in `DbAttributeSync.of(...)`. The locator can |
| 96 | +compute the PK value from the `workflowId` and/or from the current `persistence` (other data attributes |
| 97 | +already set at that point). |
| 98 | + |
| 99 | +### 3. Use the data attribute normally |
| 100 | + |
| 101 | +```java |
| 102 | +public class DecideState implements WorkflowState<String> { |
| 103 | + @Override public Class<String> getInputType() { return String.class; } |
| 104 | + |
| 105 | + @Override |
| 106 | + public StateDecision execute(Context ctx, String in, CommandResults r, |
| 107 | + Persistence persistence, Communication comm) { |
| 108 | + String status = persistence.getDataAttribute(OrderWorkflow.STATUS, String.class); // loaded from DB |
| 109 | + // ... business logic ... |
| 110 | + persistence.setDataAttribute(OrderWorkflow.STATUS, "PROCESSED"); // mirrored back to DB |
| 111 | + return StateDecision.gracefulCompleteWorkflow(status); |
| 112 | + } |
| 113 | +} |
| 114 | +``` |
| 115 | + |
| 116 | +Start the workflow as usual with the registered `Client` — the load happens automatically: |
| 117 | + |
| 118 | +```java |
| 119 | +client.startWorkflow(OrderWorkflow.class, "order-123", 3600, "start"); |
| 120 | +``` |
| 121 | + |
| 122 | +## Behavior / semantics |
| 123 | + |
| 124 | +| Aspect | Behavior | |
| 125 | +|---|---| |
| 126 | +| When load fires | Once, at workflow start (via the registered `Client`). | |
| 127 | +| When sync fires | After a state's `execute` mutates a mapped data attribute. | |
| 128 | +| Ordering | iWF persists the data attribute first; then the DB `UPDATE` runs. | |
| 129 | +| Retry / idempotency | The DB write is a single-row idempotent `UPDATE`; safe to retry. On failure only the internal sync step retries — your `execute` is not re-run. | |
| 130 | +| Stored value | The data attribute's `ObjectEncoder` JSON (same bytes iWF stores). Use a `text`/`jsonb` column. | |
| 131 | +| Multiple synced attributes | Each maps to its own cell; all load at start and each syncs when mutated. | |
| 132 | + |
| 133 | +## Column & type guidance |
| 134 | + |
| 135 | +The mapped column stores the data attribute's JSON. For a `String` attribute of value `NEW`, the column |
| 136 | +holds the JSON string `"NEW"`. For complex objects it holds the serialized JSON object. This round-trips |
| 137 | +any type uniformly, so use `text` or `jsonb`. Mapping to a native scalar column (e.g. an `integer` |
| 138 | +column) is **not** supported in v1. |
| 139 | + |
| 140 | +## Limitations (v1) |
| 141 | + |
| 142 | +- **Fixed-key data attributes only** — `DataAttributeDef.createByPrefix(...)` is not supported for DB |
| 143 | + sync (a `WorkflowDefinitionException` is thrown at registration if you try). |
| 144 | +- **`execute` mutations only** — a data attribute mutated in `waitUntil` (and not in `execute`) is not |
| 145 | + synced. Mutate DB-synced data attributes in `execute`. |
| 146 | +- **Started via the registered `Client`** — starts issued directly against the server API / another SDK |
| 147 | + with an explicit start stateId bypass the load step. |
| 148 | + |
| 149 | +## Troubleshooting |
| 150 | + |
| 151 | +| Symptom | Likely cause | |
| 152 | +|---|---| |
| 153 | +| Data attribute is `null` after start | Row/PK/column mismatch — the locator's PK value or column name doesn't match an existing row/column, or the column is null. | |
| 154 | +| Column not updated after a state | The attribute was mutated in `waitUntil` (not `execute`), or the mutated key isn't the DB-mapped one. | |
| 155 | +| `unsafe SQL identifier` error | The table / PK / data-column name isn't a plain identifier (`^[A-Za-z_][A-Za-z0-9_]*$`, optionally schema-qualified). Rename or quote-safe your identifiers. | |
| 156 | +| Connection / pool errors | Check the `DataSource` config, network access from the **worker** process to Postgres, and pool sizing. | |
| 157 | +| `WorkflowDefinitionException: DB sync is not supported for prefix data attributes` | You used `createByPrefix` with a `DbAttributeSync`; use a fixed key. | |
| 158 | + |
| 159 | +## Security notes |
| 160 | + |
| 161 | +- **Credentials** are never held by the SDK — you supply a configured `DataSource`. |
| 162 | +- **Values** (primary-key value and the data value) are always bound via `PreparedStatement` parameters. |
| 163 | +- **Identifiers** (table, PK column, data column) cannot be bound; they are validated against a strict |
| 164 | + allowlist and double-quoted, so a malformed identifier is rejected rather than injected. |
0 commit comments