Skip to content

Commit 6e65d84

Browse files
longquanzhengclaude
andcommitted
Add automatic data-attribute-to-Postgres sync
Mirror a workflow data attribute to a cell of a user-owned Postgres table: the iWF data attribute stays the source of truth; the DB cell is a mirror. - DataAttributeDef.create(type, key, DbAttributeSync) declares the mapping (DataSource + table + pk column + a (workflowId, persistence) -> CellLocation locator) - On start, Client swaps the start state to a worker-internal load state that SELECTs each mapped column, sets the data attribute, then transitions to the real starting state with the original input passed through. - When a state's execute mutates a mapped data attribute, the worker reroutes the decision through a worker-internal sync state (carrying DbSyncStatePayload) that UPDATEs the column, then replays the original decision verbatim. iWF persists first, so a DB failure retries only the sync step, not user code. - New: CellLocation, DbAttributeSync, PostgresDataAttributeSyncer (parameterized values + allowlisted/quoted identifiers, try-with-resources), DbSyncStatePayload. - Neither system state is registered; both are handled specially in WorkerService. - Integration test (DbSyncTest) against a real Postgres via docker-compose + init script (schema/table/seed); CI wired with a Postgres readiness wait. - Docs: user guide + concise overview, linked from README. Scope (v1): fixed-key data attributes only; sync on execute mutations only; load fires for starts via the registered Client; column stores the attribute JSON. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8fa0445 commit 6e65d84

18 files changed

Lines changed: 924 additions & 3 deletions

.github/workflows/ci-integ-test.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ jobs:
2020
- uses: gradle/gradle-build-action@v2
2121
with:
2222
gradle-version: 7.5
23+
- name: "Wait for integ Postgres to be ready"
24+
run: |
25+
for i in $(seq 1 30); do
26+
if docker exec iwf-integ-postgres pg_isready -U iwf -d iwf_integ; then
27+
echo "postgres is ready"; exit 0
28+
fi
29+
echo "waiting for postgres... ($i)"; sleep 2
30+
done
31+
echo "postgres did not become ready in time" >&2; exit 1
2332
- run: git submodule update --init --recursive && sleep 30 && gradle build
2433
- name: Dump docker logs
2534
if: always()

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ A workflow can contain any number of WorkflowStates.
5353

5454
See more in https://github.com/indeedeng/iwf#what-is-iwf
5555

56+
## Features
57+
58+
* [Data Attribute → Postgres sync](./docs/data-attribute-db-sync-overview.md) — automatically mirror a
59+
data attribute to a cell of a user-owned Postgres table (load on start, sync on mutation).
60+
5661
## How to build & run
5762

5863
### Using IntelliJ

build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ dependencies {
7575
testImplementation 'org.springframework.boot:spring-boot-starter-test'
7676
testImplementation "javax.validation:validation-api:2.0.1.Final"
7777

78+
// Postgres JDBC driver for the data-attribute DB-sync integration test (test-only; the core SDK
79+
// stays driver-agnostic and depends only on javax.sql.DataSource).
80+
testImplementation 'org.postgresql:postgresql:42.7.4'
81+
7882

7983
// immutable
8084
compileOnly 'org.immutables:value:2.9.2'
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Data Attribute → Postgres sync (overview)
2+
3+
Automatically mirror a workflow **data attribute** to a cell (row + column) of a user-owned Postgres
4+
table. The iWF data attribute stays the source of truth; the database cell is a mirror.
5+
6+
- **Load on start** — when the workflow starts, the data attribute is loaded from its mapped column.
7+
- **Sync on mutation** — whenever the data attribute is mutated in a state's `execute` method, the new
8+
value is written back to the same column.
9+
10+
```java
11+
DataSource ds = /* your configured, pooled DataSource */;
12+
13+
DataAttributeDef.create(
14+
String.class,
15+
"status",
16+
DbAttributeSync.of(ds, "public.orders", "id",
17+
(workflowId, persistence) -> new CellLocation(workflowId, "status_col")));
18+
```
19+
20+
## How it works (3 lines)
21+
22+
1. `Client.startWorkflow` starts at a load system state that `SELECT`s each mapped column and sets the
23+
data attribute, then transitions to your real starting state (input passes through).
24+
2. When a state's `execute` mutates a mapped data attribute, the worker reroutes the decision through a
25+
sync system state that `UPDATE`s the column, then continues to your original decision.
26+
3. iWF persists the data attribute first, then the DB write happens — so a DB failure retries only the
27+
sync step, never your `execute`. Neither system state is registered; both are worker-internal.
28+
29+
## Key limitations (v1)
30+
31+
- Fixed-key data attributes only (not `createByPrefix`).
32+
- Sync fires on `execute` mutations only (not `waitUntil`).
33+
- Load fires for workflows started through the registered `Client`.
34+
- The mapped column stores the data attribute's JSON — use a `text`/`jsonb` column.
35+
36+
See the detailed guide: [data-attribute-db-sync.md](./data-attribute-db-sync.md).

docs/data-attribute-db-sync.md

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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.

script/docker-compose.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,20 @@ services:
6666
volumes:
6767
- ./docker-compose-init.sh:/etc/temporal/init.sh
6868
entrypoint: sh -c "/etc/temporal/init.sh"
69+
iwf-integ-postgres:
70+
container_name: iwf-integ-postgres
71+
image: postgres:${POSTGRESQL_VERSION}
72+
environment:
73+
POSTGRES_DB: iwf_integ
74+
POSTGRES_USER: iwf
75+
POSTGRES_PASSWORD: iwf
76+
ports:
77+
# host port 5455 (5432 is often occupied by other local Postgres containers); container is 5432
78+
- 5455:5432
79+
volumes:
80+
- ./postgres-init:/docker-entrypoint-initdb.d
81+
networks:
82+
- temporal-network
6983
iwf-server:
7084
container_name: iwf-server
7185
image: iworkflowio/iwf-server:latest #NOTE: you can change to version tag like v1.0.0-RC6
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-- Initializes the schema/table and the initial dataset used by the data-attribute DB-sync
2+
-- integration test (io.iworkflow.integ.DbSyncTest). Postgres runs any file in
3+
-- /docker-entrypoint-initdb.d/ once on first container start, against POSTGRES_DB (iwf_integ).
4+
5+
CREATE TABLE IF NOT EXISTS iwf_integ_data (
6+
pk TEXT PRIMARY KEY,
7+
data_col TEXT
8+
);
9+
10+
-- The value is stored as JSON text (the SDK mirrors the data attribute's ObjectEncoder JSON), so a
11+
-- String data attribute of value seeded-value is stored as the JSON string "seeded-value".
12+
INSERT INTO iwf_integ_data (pk, data_col)
13+
VALUES ('db-sync-test-key', '"seeded-value"')
14+
ON CONFLICT (pk) DO UPDATE SET data_col = EXCLUDED.data_col;

src/main/java/io/iworkflow/core/Client.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,14 @@ public String startWorkflow(
175175
if (stateOptions != null) {
176176
unregisterWorkflowOptions.startStateOptions(stateOptions);
177177
}
178+
179+
// If the workflow has DB-synced data attributes, start at the load system state instead of
180+
// the real starting state. The load state reads the mapped columns and then transitions to
181+
// the real starting state, passing the original input (sent as stateInput) through.
182+
if (!registry.getDbAttributeSyncs(wfType).isEmpty()) {
183+
startStateId = WorkerService.LOAD_DATA_ATTRIBUTES_FROM_DB_STATE_ID;
184+
unregisterWorkflowOptions.startStateOptions(new WorkflowStateOptions().skipWaitUntil(true));
185+
}
178186
}
179187

180188
final PersistenceOptions schemaOptions = registry.getPersistenceOptions(wfType);

src/main/java/io/iworkflow/core/Registry.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
import io.iworkflow.core.communication.InternalChannelDef;
44
import io.iworkflow.core.communication.SignalChannelDef;
55
import io.iworkflow.core.persistence.DataAttributeDef;
6+
import io.iworkflow.core.persistence.DbAttributeSync;
67
import io.iworkflow.core.persistence.PersistenceFieldDef;
78
import io.iworkflow.core.persistence.PersistenceOptions;
89
import io.iworkflow.core.persistence.SearchAttributeDef;
910
import io.iworkflow.gen.models.SearchAttributeValueType;
1011

1112
import java.lang.reflect.Method;
1213
import java.util.Arrays;
14+
import java.util.Collections;
1315
import java.util.HashMap;
1416
import java.util.List;
1517
import java.util.Map;
@@ -27,6 +29,8 @@ public class Registry {
2729
private final Map<String, TypeStore> signalTypeStore = new HashMap<>();
2830
private final Map<String, TypeStore> internalChannelTypeStore = new HashMap<>();
2931
private final Map<String, TypeStore> dataAttributeTypeStore = new HashMap<>();
32+
// (workflow type) -> (data attribute key -> DB sync mapping)
33+
private final Map<String, Map<String, DbAttributeSync>> dbAttributeSyncStore = new HashMap<>();
3034

3135
private final Map<String, Map<String, SearchAttributeValueType>> searchAttributeTypeStore = new HashMap<>();
3236

@@ -153,6 +157,17 @@ private void registerWorkflowDataAttributes(final ObjectWorkflow wf) {
153157

154158
for (final DataAttributeDef dataAttributeField : fields) {
155159
typeStore.addToStore(dataAttributeField);
160+
161+
final DbAttributeSync dbSync = dataAttributeField.getDbSync();
162+
if (dbSync != null) {
163+
if (Boolean.TRUE.equals(dataAttributeField.isPrefix())) {
164+
throw new WorkflowDefinitionException(
165+
"DB sync is not supported for prefix data attributes: " + dataAttributeField.getKey());
166+
}
167+
dbAttributeSyncStore
168+
.computeIfAbsent(workflowType, s -> new HashMap<>())
169+
.put(dataAttributeField.getKey(), dbSync);
170+
}
156171
}
157172
}
158173

@@ -250,6 +265,14 @@ public TypeStore getDataAttributeTypeStore(final String workflowType) {
250265
return dataAttributeTypeStore.get(workflowType);
251266
}
252267

268+
/**
269+
* @return the data-attribute-key -> DB sync mapping for the workflow type, or an empty map when
270+
* the workflow has no DB-synced data attributes.
271+
*/
272+
public Map<String, DbAttributeSync> getDbAttributeSyncs(final String workflowType) {
273+
return dbAttributeSyncStore.getOrDefault(workflowType, Collections.emptyMap());
274+
}
275+
253276
public Map<String, SearchAttributeValueType> getSearchAttributeKeyToTypeMap(final String workflowType) {
254277
return searchAttributeTypeStore.get(workflowType);
255278
}

0 commit comments

Comments
 (0)