@siglo/postgres 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +365 -87
- package/package.json +15 -4
package/README.md
CHANGED
|
@@ -1,72 +1,85 @@
|
|
|
1
1
|
# @siglo/postgres Adapter Guide
|
|
2
2
|
|
|
3
|
-
`@siglo/postgres` provides an enterprise ACID-compliant persistence
|
|
3
|
+
`@siglo/postgres` provides an enterprise ACID-compliant persistence, event auditing, distributed locking, and idempotency coordination layer for `Siglo` workflows using PostgreSQL. It is engineered for mission-critical transactional workflows requiring zero state loss across crashes, network partitions, and multi-node worker clusters.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
## 1. Overview & Architecture
|
|
8
8
|
|
|
9
|
-
When workflows coordinate
|
|
9
|
+
When workflows coordinate financial, compliance, or enterprise operations, persistence must survive node failures and process crashes. `@siglo/postgres` implements two primary classes:
|
|
10
|
+
|
|
11
|
+
1. **`PostgresStorageAdapter`**: Implements the Siglo `StorageAdapter` interface for durable execution checkpoints with Optimistic Concurrency Control (OCC), immutable context mutation audit logging, durable asynchronous wait/continuation records, and distributed session-level advisory workflow locking.
|
|
12
|
+
2. **`PostgresIdempotencyStore`**: Implements the Siglo `IdempotencyStore` interface for atomic, distributed single-flight execution claims, cached result retrieval, and automated lock re-claiming upon TTL expiration.
|
|
10
13
|
|
|
11
14
|
```mermaid
|
|
12
15
|
flowchart TD
|
|
13
|
-
Engine["Siglo Engine"]
|
|
16
|
+
Engine["Siglo Workflow Engine"]
|
|
14
17
|
|
|
15
|
-
subgraph Adapters["@siglo/postgres"]
|
|
16
|
-
direction
|
|
17
|
-
SA["PostgresStorageAdapter<br/>• saveCheckpoint()<br/>• getCheckpoint()<br/>• onContextChange()<br/>• cleanupOldCheckpoints()"]
|
|
18
|
-
IS["PostgresIdempotencyStore<br/>• claim()<br/>• complete()<br/>•
|
|
18
|
+
subgraph Adapters["@siglo/postgres Adapter Suite"]
|
|
19
|
+
direction TB
|
|
20
|
+
SA["PostgresStorageAdapter<br/>• saveCheckpoint() (OCC Versioned)<br/>• getCheckpoint()<br/>• onContextChange()<br/>• withWorkflowLock() (Advisory Lock)<br/>• saveWaitRecord() / claimWaitRecord()<br/>• completeWaitRecord() / cancelWorkflow()<br/>• cleanExpiredCheckpoints()<br/>• cleanupOldCheckpoints() / Events / Waits"]
|
|
21
|
+
IS["PostgresIdempotencyStore<br/>• claim() (Atomic Insert/Reclaim)<br/>• complete() / fail()<br/>• getResult() / get() / getRecord()<br/>• cleanupExpired()"]
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
subgraph DB["PostgreSQL Database"]
|
|
22
|
-
direction
|
|
23
|
-
T1["siglo_checkpoints<br/>• stack_id (PK)<br/>• namespace<br/>• state (JSONB)<br/>• created_at /
|
|
24
|
-
T2["siglo_context_events<br/>• id (BIGSERIAL PK)<br/>• stack_id, step_id<br/>• namespace, field<br/>• value (JSONB), created_at"]
|
|
25
|
-
T3["
|
|
25
|
+
direction TB
|
|
26
|
+
T1[("siglo_checkpoints<br/>• stack_id (PK)<br/>• namespace<br/>• state (JSONB)<br/>• version (BIGINT OCC)<br/>• created_at / updated_at")]
|
|
27
|
+
T2[("siglo_context_events<br/>• id (BIGSERIAL PK)<br/>• stack_id, step_id<br/>• namespace, field<br/>• value (JSONB), created_at")]
|
|
28
|
+
T3[("siglo_wait_records<br/>• correlation_key (PK)<br/>• stack_id, status<br/>• data, resume_payload<br/>• locked_by, locked_at<br/>• expires_at, created_at")]
|
|
29
|
+
T4[("siglo_idempotency<br/>• key (VARCHAR(512) PK)<br/>• status, result (JSONB)<br/>• expires_at, created_at")]
|
|
30
|
+
AL["PostgreSQL Advisory Locks<br/>pg_try_advisory_lock(hashtextextended('siglo:workflow:' || stackId, 0))"]
|
|
26
31
|
end
|
|
27
32
|
|
|
28
33
|
Engine --> SA
|
|
29
34
|
Engine --> IS
|
|
30
35
|
SA --> T1
|
|
31
36
|
SA --> T2
|
|
32
|
-
|
|
37
|
+
SA --> T3
|
|
38
|
+
SA -.-> AL
|
|
39
|
+
IS --> T4
|
|
33
40
|
```
|
|
34
41
|
|
|
35
42
|
---
|
|
36
43
|
|
|
37
44
|
## 2. Installation & Requirements
|
|
38
45
|
|
|
39
|
-
Install `@siglo/postgres` and `pg
|
|
46
|
+
Install `@siglo/postgres` alongside `@siglo/core` and the `pg` driver:
|
|
40
47
|
|
|
41
48
|
```bash
|
|
42
49
|
npm install @siglo/core @siglo/postgres pg
|
|
43
50
|
npm install --save-dev @types/pg
|
|
44
51
|
```
|
|
45
52
|
|
|
46
|
-
Peer
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
### Peer Dependency Matrix
|
|
54
|
+
|
|
55
|
+
| Package | Version | Requirement |
|
|
56
|
+
| :--- | :--- | :--- |
|
|
57
|
+
| `@siglo/core` | `^1.0.0` | Core workflow engine & interfaces |
|
|
58
|
+
| `pg` | `^8.0.0` | PostgreSQL client driver (supports both `Pool` and `Client`) |
|
|
59
|
+
| PostgreSQL Server | `12+` | Requires JSONB, Advisory Locks (`hashtextextended`), and `TIMESTAMPTZ` |
|
|
49
60
|
|
|
50
61
|
---
|
|
51
62
|
|
|
52
|
-
## 3. Database Schema
|
|
63
|
+
## 3. Database Schema & Automated DDL
|
|
53
64
|
|
|
54
|
-
Both `PostgresStorageAdapter` and `PostgresIdempotencyStore`
|
|
65
|
+
Both `PostgresStorageAdapter` and `PostgresIdempotencyStore` provide automated schema synchronization via `ensureTables()` and `ensureTable()`. These methods execute idempotent DDL statements (`CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`).
|
|
55
66
|
|
|
56
|
-
### DDL Definitions
|
|
67
|
+
### DDL Definitions
|
|
57
68
|
|
|
58
69
|
```sql
|
|
59
|
-
-- 1. Checkpoint Storage Table
|
|
70
|
+
-- 1. Checkpoint Storage Table (with OCC version tracking)
|
|
60
71
|
CREATE TABLE IF NOT EXISTS siglo_checkpoints (
|
|
61
72
|
stack_id VARCHAR(255) PRIMARY KEY,
|
|
62
73
|
namespace VARCHAR(255),
|
|
63
74
|
state JSONB NOT NULL,
|
|
75
|
+
version BIGINT NOT NULL DEFAULT 1,
|
|
64
76
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
65
77
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
66
78
|
);
|
|
79
|
+
ALTER TABLE siglo_checkpoints ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 1;
|
|
67
80
|
CREATE INDEX IF NOT EXISTS idx_siglo_checkpoints_namespace ON siglo_checkpoints(namespace);
|
|
68
81
|
|
|
69
|
-
-- 2. Context Mutation Audit Log
|
|
82
|
+
-- 2. Step Context Mutation Audit Event Log
|
|
70
83
|
CREATE TABLE IF NOT EXISTS siglo_context_events (
|
|
71
84
|
id BIGSERIAL PRIMARY KEY,
|
|
72
85
|
stack_id VARCHAR(255) NOT NULL,
|
|
@@ -78,7 +91,29 @@ CREATE TABLE IF NOT EXISTS siglo_context_events (
|
|
|
78
91
|
);
|
|
79
92
|
CREATE INDEX IF NOT EXISTS idx_siglo_context_events_stack ON siglo_context_events(stack_id);
|
|
80
93
|
|
|
81
|
-
-- 3.
|
|
94
|
+
-- 3. Durable Wait and Continuation Records Table
|
|
95
|
+
CREATE TABLE IF NOT EXISTS siglo_wait_records (
|
|
96
|
+
correlation_key VARCHAR(255) PRIMARY KEY,
|
|
97
|
+
stack_id VARCHAR(255) NOT NULL,
|
|
98
|
+
namespace VARCHAR(255),
|
|
99
|
+
event_name VARCHAR(255),
|
|
100
|
+
status VARCHAR(50) NOT NULL DEFAULT 'WAITING',
|
|
101
|
+
continuation_step_index INT NOT NULL DEFAULT 0,
|
|
102
|
+
version VARCHAR(50),
|
|
103
|
+
data JSONB,
|
|
104
|
+
resumed_by VARCHAR(255),
|
|
105
|
+
resumed_at TIMESTAMPTZ,
|
|
106
|
+
expires_at TIMESTAMPTZ,
|
|
107
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
108
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
109
|
+
);
|
|
110
|
+
ALTER TABLE siglo_wait_records ADD COLUMN IF NOT EXISTS resume_payload JSONB;
|
|
111
|
+
ALTER TABLE siglo_wait_records ADD COLUMN IF NOT EXISTS locked_by TEXT;
|
|
112
|
+
ALTER TABLE siglo_wait_records ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ;
|
|
113
|
+
CREATE INDEX IF NOT EXISTS idx_siglo_wait_records_stack ON siglo_wait_records(stack_id);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_siglo_wait_records_status ON siglo_wait_records(status);
|
|
115
|
+
|
|
116
|
+
-- 4. Distributed Idempotency Table
|
|
82
117
|
CREATE TABLE IF NOT EXISTS siglo_idempotency (
|
|
83
118
|
key VARCHAR(512) PRIMARY KEY,
|
|
84
119
|
status VARCHAR(50) NOT NULL,
|
|
@@ -89,11 +124,18 @@ CREATE TABLE IF NOT EXISTS siglo_idempotency (
|
|
|
89
124
|
CREATE INDEX IF NOT EXISTS idx_siglo_idempotency_expires ON siglo_idempotency(expires_at);
|
|
90
125
|
```
|
|
91
126
|
|
|
127
|
+
### SQL Injection Protection
|
|
128
|
+
|
|
129
|
+
Custom table names passed to `PostgresStorageAdapter` or `PostgresIdempotencyStore` are validated against strict SQL identifier constraints:
|
|
130
|
+
- Must match `/^[a-zA-Z_][a-zA-Z0-9_]*$/`.
|
|
131
|
+
- Must not exceed 63 characters (PostgreSQL standard identifier length limit).
|
|
132
|
+
- Throws an descriptive `Error` if invalid characters or injection attempts are detected.
|
|
133
|
+
|
|
92
134
|
---
|
|
93
135
|
|
|
94
136
|
## 4. `PostgresStorageAdapter` API Reference
|
|
95
137
|
|
|
96
|
-
Implements the
|
|
138
|
+
Implements the Siglo `StorageAdapter` interface (`name = 'postgres'`).
|
|
97
139
|
|
|
98
140
|
```typescript
|
|
99
141
|
import { PostgresStorageAdapter } from '@siglo/postgres';
|
|
@@ -103,36 +145,134 @@ import { PostgresStorageAdapter } from '@siglo/postgres';
|
|
|
103
145
|
|
|
104
146
|
| Option | Type | Default | Description |
|
|
105
147
|
| :--- | :--- | :--- | :--- |
|
|
106
|
-
| `client` | `PostgresClientInterface \| Pool` | *Required* | Active PostgreSQL `Pool` or `
|
|
107
|
-
| `checkpointsTable` | `string` | `'siglo_checkpoints'` |
|
|
108
|
-
| `eventsTable` | `string` | `'siglo_context_events'` |
|
|
109
|
-
| `
|
|
148
|
+
| `client` | `PostgresClientInterface \| Pool \| Client` | *Required* | Active PostgreSQL `Pool`, `Client`, or compatible wrapper with a `.query(text, values)` method. |
|
|
149
|
+
| `checkpointsTable` | `string` | `'siglo_checkpoints'` | Table name for execution checkpoints. |
|
|
150
|
+
| `eventsTable` | `string` | `'siglo_context_events'` | Table name for context mutation audit logs. |
|
|
151
|
+
| `waitRecordsTable` | `string` | `'siglo_wait_records'` | Table name for durable wait and continuation records. |
|
|
152
|
+
| `recordContextChanges` | `boolean` | `true` | When `true`, automatically streams step context mutations into `eventsTable`. Set `false` for low-latency pipelines that do not require fine-grained field auditing. |
|
|
110
153
|
|
|
111
|
-
###
|
|
154
|
+
### Checkpointing & Optimistic Concurrency Control (OCC)
|
|
112
155
|
|
|
113
156
|
#### `ensureTables(): Promise<void>`
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
#### `saveCheckpoint(checkpoint: SerializedExecutionStack): Promise<void>`
|
|
117
|
-
|
|
157
|
+
Initializes `checkpointsTable`, `eventsTable`, and `waitRecordsTable` along with indexes and columns if they do not exist.
|
|
158
|
+
|
|
159
|
+
#### `saveCheckpoint(checkpoint: SerializedExecutionStack, expectedVersion?: number): Promise<void>`
|
|
160
|
+
Persists or updates an execution stack.
|
|
161
|
+
- **OCC Enforcement**: If `expectedVersion` is specified (or exists on `checkpoint.occVersion`), executes an atomic version-guarded update:
|
|
162
|
+
```sql
|
|
163
|
+
UPDATE siglo_checkpoints
|
|
164
|
+
SET state = CASE WHEN state->>'status' = 'cancelled' THEN jsonb_set($1::jsonb, '{status}', '"cancelled"') ELSE $1::jsonb END,
|
|
165
|
+
namespace = $2,
|
|
166
|
+
version = version + 1,
|
|
167
|
+
updated_at = NOW()
|
|
168
|
+
WHERE stack_id = $3 AND version = $4
|
|
169
|
+
RETURNING version
|
|
170
|
+
```
|
|
171
|
+
- **Conflict Handling**: If `rowCount === 0`, it fetches the current database version and throws an `OptimisticLockException(stackId, currentVersion, expectedVersion)`.
|
|
172
|
+
- **Cancellation Preservation**: If a workflow was previously cancelled, updating the checkpoint preserves the `'cancelled'` status atomically.
|
|
173
|
+
- **Version Tracking**: On success, assigns the new incremented version to `checkpoint.occVersion` and `checkpoint.revision`.
|
|
174
|
+
- **Write Tracking**: Automatically serialized under `withWorkflowLock` scopes to drain all pending writes before lock release.
|
|
118
175
|
|
|
119
176
|
#### `getCheckpoint(stackId: string): Promise<SerializedExecutionStack | undefined>`
|
|
120
|
-
|
|
177
|
+
Retrieves the serialized execution stack by `stackId`.
|
|
178
|
+
- Automatically assigns `occVersion` and `revision` from the database `version` column.
|
|
179
|
+
- Returns `undefined` if `stackId` is empty or not found.
|
|
180
|
+
|
|
181
|
+
#### `cleanExpiredCheckpoints(): Promise<number>`
|
|
182
|
+
Deletes checkpoints where `(state->>'expiresAt')::bigint <= (EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`. Returns the count of deleted rows.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
### Context Mutation Audit Logging
|
|
121
187
|
|
|
122
188
|
#### `onContextChange(entry): Promise<void>`
|
|
123
|
-
|
|
189
|
+
Records step mutation audit events into `eventsTable`.
|
|
190
|
+
- Parameter `entry`:
|
|
191
|
+
```typescript
|
|
192
|
+
{
|
|
193
|
+
namespace?: string;
|
|
194
|
+
stackId: string;
|
|
195
|
+
stepId: string;
|
|
196
|
+
field: string;
|
|
197
|
+
value: any;
|
|
198
|
+
timestamp: number;
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
- Converts `timestamp` (in milliseconds) using `to_timestamp(timestamp / 1000.0)`.
|
|
202
|
+
- Operates as a non-blocking stream: internal errors are caught to avoid disrupting primary workflow execution.
|
|
203
|
+
|
|
204
|
+
---
|
|
124
205
|
|
|
125
|
-
|
|
126
|
-
Deletes checkpoints older than `retentionDays` (default `30` days). Returns the count of deleted rows.
|
|
206
|
+
### Distributed Workflow Locking
|
|
127
207
|
|
|
128
|
-
#### `
|
|
129
|
-
|
|
208
|
+
#### `withWorkflowLock<T>(stackId: string, action: () => Promise<T>): Promise<T>`
|
|
209
|
+
Executes `action` inside a PostgreSQL session-level advisory lock using `pg_try_advisory_lock`:
|
|
210
|
+
|
|
211
|
+
```sql
|
|
212
|
+
SELECT pg_try_advisory_lock(hashtextextended('siglo:workflow:' || stackId, 0)) AS locked
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- **Distributed Mutex**: Serializes step continuations, event resumptions, and compensations across multi-instance worker fleets.
|
|
216
|
+
- **Auto-Release on Process Failure**: Because PostgreSQL advisory locks are bound to the database session/connection, if a worker node crashes or disconnects, PostgreSQL automatically releases the lock immediately.
|
|
217
|
+
- **Re-entrancy**: Safely allows nested invocations for the same `stackId` within the same execution context.
|
|
218
|
+
- **Pool Awareness**: Detects pooled clients (`'totalCount' in pool`) and calls `pool.connect()`, ensuring a dedicated connection is held for the lock duration and released back to the pool in a `finally` block.
|
|
219
|
+
- **Non-Pooled / Direct Client Guard**: Guards direct `Client` instances against concurrent re-use via `directClientBusy`.
|
|
220
|
+
- **Write Queue Draining**: Uses `AsyncLocalStorage` and internal write tracking (`tail` promise) to ensure all pending checkpoint and wait record writes are completely flushed to PostgreSQL before the advisory lock is unlocked.
|
|
221
|
+
- **Throws**: `DuplicateEventException(stackId)` if the lock cannot be acquired or if the direct client is busy.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
### Durable Wait Records & Resumption
|
|
226
|
+
|
|
227
|
+
#### `saveWaitRecord(record: any): Promise<void>`
|
|
228
|
+
Persists or updates a durable wait record in `waitRecordsTable` and embeds the record into the checkpoint metadata (`checkpoint.metadata.waitRecord`).
|
|
229
|
+
- Key properties on `record`: `correlationKey`, `stackId`, `namespace`, `eventName`, `status`, `continuationStepIndex`, `version`, `data`, `expiresAt`.
|
|
230
|
+
|
|
231
|
+
#### `getWaitRecord(correlationKeyOrStackId: string): Promise<any | undefined>`
|
|
232
|
+
Retrieves a wait record from `waitRecordsTable` matching either `correlation_key` or `stack_id`. Falls back to querying `siglo_checkpoints.state->'metadata'->'waitRecord'` if not found in the wait records table.
|
|
233
|
+
|
|
234
|
+
#### `claimWaitRecord(correlationKey: string, claimantId?: string, options?: { allowExpired?: boolean; eventPayload?: any }): Promise<any>`
|
|
235
|
+
Atomically claims a wait record for resumption via a single Compare-And-Swap (CAS) update:
|
|
236
|
+
```sql
|
|
237
|
+
UPDATE siglo_wait_records
|
|
238
|
+
SET status = 'RESUMING', locked_at = NOW(), locked_by = $1,
|
|
239
|
+
resume_payload = COALESCE(resume_payload, $3::jsonb), updated_at = NOW()
|
|
240
|
+
WHERE correlation_key = $2
|
|
241
|
+
AND (status = 'WAITING' OR (status = 'RESUMING' AND $5))
|
|
242
|
+
AND (resume_payload IS NOT NULL OR $4 OR expires_at IS NULL OR expires_at > NOW())
|
|
243
|
+
RETURNING *
|
|
244
|
+
```
|
|
245
|
+
- **Expiration Safety**: If the record has expired and `allowExpired` is not set, throws `ExpiredWaitException(correlationKey, expiresAt, eventName)`.
|
|
246
|
+
- **Duplicate Safety**: If the record is already claimed or resumed, throws `DuplicateEventException(correlationKey)`.
|
|
247
|
+
- **Returns**: The claimed wait record with `status: 'RESUMING'`, `lockedBy`, and parsed JSON `resumePayload`.
|
|
248
|
+
|
|
249
|
+
#### `completeWaitRecord(correlationKey: string, claimantId: string): Promise<void>`
|
|
250
|
+
Marks the claimed wait record as `'RESUMED'`, setting `resumed_by` and `resumed_at = NOW()`. Drains pending async writes before completing.
|
|
251
|
+
|
|
252
|
+
#### `cancelWaitRecord(correlationKey: string): Promise<void>`
|
|
253
|
+
Transitions a wait record status to `'CANCELLED'`.
|
|
254
|
+
|
|
255
|
+
#### `cancelWorkflow(stackId: string): Promise<void>`
|
|
256
|
+
Atomically cancels both active wait records and the execution checkpoint for `stackId`:
|
|
257
|
+
- Cancels all wait records: `UPDATE siglo_wait_records SET status = 'CANCELLED' WHERE stack_id = $1`.
|
|
258
|
+
- Updates the checkpoint state: `UPDATE siglo_checkpoints SET state = jsonb_set(state, '{status}', '"cancelled"'), version = version + 1 WHERE stack_id = $1`.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
### Retention & Cleanup
|
|
263
|
+
|
|
264
|
+
| Method | Parameters | Default | Description |
|
|
265
|
+
| :--- | :--- | :--- | :--- |
|
|
266
|
+
| `cleanupOldCheckpoints` | `retentionDays?: number` | `30` | Deletes checkpoints where `updated_at < NOW() - make_interval(days => $1)`. |
|
|
267
|
+
| `cleanupOldEvents` | `retentionDays?: number` | `30` | Deletes audit events where `created_at < NOW() - make_interval(days => $1)`. |
|
|
268
|
+
| `cleanupOldWaitRecords` | `retentionDays?: number` | `30` | Deletes wait records where `updated_at < NOW() - make_interval(days => $1)`. |
|
|
269
|
+
| `cleanExpiredCheckpoints` | *(none)* | *(none)* | Deletes checkpoints whose embedded `state->>'expiresAt'` timestamp has passed. |
|
|
130
270
|
|
|
131
271
|
---
|
|
132
272
|
|
|
133
273
|
## 5. `PostgresIdempotencyStore` API Reference
|
|
134
274
|
|
|
135
|
-
Implements the
|
|
275
|
+
Implements the Siglo `IdempotencyStore` interface.
|
|
136
276
|
|
|
137
277
|
```typescript
|
|
138
278
|
import { PostgresIdempotencyStore } from '@siglo/postgres';
|
|
@@ -142,40 +282,122 @@ import { PostgresIdempotencyStore } from '@siglo/postgres';
|
|
|
142
282
|
|
|
143
283
|
| Option | Type | Default | Description |
|
|
144
284
|
| :--- | :--- | :--- | :--- |
|
|
145
|
-
| `client` | `PostgresClientInterface \| Pool` | *Required* | Active PostgreSQL
|
|
146
|
-
| `tableName` | `string` | `'siglo_idempotency'` | Name of idempotency table. Must match `/^[a-zA-Z_][a-zA-Z0-9_]
|
|
147
|
-
| `defaultTtlMs` | `number` | `86400000` (
|
|
285
|
+
| `client` | `PostgresClientInterface \| Pool \| Client` | *Required* | Active PostgreSQL client or connection pool. |
|
|
286
|
+
| `tableName` | `string` | `'siglo_idempotency'` | Name of idempotency table. Must match `/^[a-zA-Z_][a-zA-Z0-9_]*$/`. |
|
|
287
|
+
| `defaultTtlMs` | `number` | `86400000` (24 hours) | Default lock and result expiration TTL in milliseconds. |
|
|
148
288
|
|
|
149
289
|
### Methods
|
|
150
290
|
|
|
151
291
|
#### `ensureTable(): Promise<void>`
|
|
152
|
-
|
|
292
|
+
Creates `tableName` and the index on `expires_at` if they do not exist.
|
|
153
293
|
|
|
154
294
|
#### `claim(key: string, ttlMs?: number): Promise<'claimed' | 'completed' | 'in-progress'>`
|
|
155
|
-
Atomically attempts to
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
295
|
+
Atomically attempts to claim the key:
|
|
296
|
+
1. **Initial Insert**: Executes `INSERT ... VALUES ($1, 'in-progress', $2, NOW()) ON CONFLICT (key) DO NOTHING`. Returns `'claimed'` if row inserted.
|
|
297
|
+
2. **Expired Lock Reclamation**: If the key exists but `expires_at <= NOW()`, updates status back to `'in-progress'`, updates `expires_at`, clears `result`, and returns `'claimed'`.
|
|
298
|
+
3. **Completed Result**: If `status === 'completed'`, returns `'completed'`.
|
|
299
|
+
4. **Active Claim**: If actively running and unexpired, returns `'in-progress'`.
|
|
159
300
|
|
|
160
301
|
#### `complete(key: string, result: unknown, ttlMs?: number): Promise<void>`
|
|
161
|
-
Transitions key status to `'completed'`, serializes
|
|
302
|
+
Transitions key status to `'completed'`, serializes `result` into `result JSONB`, and sets `expires_at = NOW() + ttlMs`.
|
|
162
303
|
|
|
163
|
-
#### `fail(key: string,
|
|
164
|
-
Deletes in-progress claims so subsequent retries can proceed.
|
|
304
|
+
#### `fail(key: string, _error?: unknown): Promise<void>`
|
|
305
|
+
Deletes in-progress claims (`WHERE key = $1 AND status = 'in-progress'`) so subsequent retries can proceed immediately.
|
|
165
306
|
|
|
166
307
|
#### `getResult(key: string): Promise<unknown | undefined>`
|
|
167
|
-
Retrieves the stored JSONB
|
|
308
|
+
Retrieves and deserializes the stored `result` JSONB for completed keys. Returns `undefined` if key is not completed or missing.
|
|
309
|
+
|
|
310
|
+
#### `get(key: string): Promise<unknown | undefined>`
|
|
311
|
+
Convenience alias for `getResult(key)`.
|
|
312
|
+
|
|
313
|
+
#### `getRecord(key: string): Promise<any | undefined>`
|
|
314
|
+
Inspects the full raw idempotency record:
|
|
315
|
+
```typescript
|
|
316
|
+
{
|
|
317
|
+
status: string; // 'in-progress' | 'completed'
|
|
318
|
+
result?: any; // Parsed JSON result
|
|
319
|
+
expiresAt?: number; // Expiry timestamp (ms)
|
|
320
|
+
claimedAt: number; // Created timestamp (ms)
|
|
321
|
+
}
|
|
322
|
+
```
|
|
168
323
|
|
|
169
324
|
#### `cleanupExpired(): Promise<number>`
|
|
170
|
-
|
|
325
|
+
Deletes expired idempotency records (`DELETE FROM siglo_idempotency WHERE expires_at < NOW()`). Returns the count of deleted rows.
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## 6. Concurrency & Locking Architecture
|
|
330
|
+
|
|
331
|
+
Siglo workflows employ a defense-in-depth concurrency model combining PostgreSQL advisory session locks with table-level Optimistic Concurrency Control (OCC):
|
|
332
|
+
|
|
333
|
+
```mermaid
|
|
334
|
+
sequenceDiagram
|
|
335
|
+
autonumber
|
|
336
|
+
participant W1 as Worker Pod A
|
|
337
|
+
participant PG as PostgreSQL
|
|
338
|
+
participant W2 as Worker Pod B
|
|
339
|
+
|
|
340
|
+
Note over W1,PG: 1. Advisory Workflow Locking (Distributed Mutex)
|
|
341
|
+
W1->>PG: SELECT pg_try_advisory_lock(hashtextextended('siglo:workflow:stack-42', 0))
|
|
342
|
+
PG-->>W1: locked = true
|
|
343
|
+
|
|
344
|
+
W2->>PG: SELECT pg_try_advisory_lock(hashtextextended('siglo:workflow:stack-42', 0))
|
|
345
|
+
PG-->>W2: locked = false (Throws DuplicateEventException)
|
|
346
|
+
|
|
347
|
+
Note over W1,PG: 2. Execution & OCC Checkpoint Persistence
|
|
348
|
+
W1->>PG: UPDATE siglo_checkpoints SET state = $1, version = version + 1 WHERE stack_id = 'stack-42' AND version = 1 RETURNING version
|
|
349
|
+
PG-->>W1: version = 2
|
|
350
|
+
|
|
351
|
+
Note over W1,PG: 3. Automatic Write Queue Flush & Session Unlock
|
|
352
|
+
W1->>W1: Await pending writes (scope.tail)
|
|
353
|
+
W1->>PG: SELECT pg_advisory_unlock(hashtextextended('siglo:workflow:stack-42', 0))
|
|
354
|
+
PG-->>W1: unlocked = true
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Advisory Locks vs. Optimistic Concurrency Control (OCC)
|
|
358
|
+
|
|
359
|
+
- **`withWorkflowLock` (PostgreSQL Advisory Locks)**: Used around step executions, timer/event resumptions, and compensations. Guarantees that only **one worker** executes steps for a given `stackId` at any time. If the worker crashes, the PostgreSQL session lock automatically drops.
|
|
360
|
+
- **OCC (`version` column)**: Used by `saveCheckpoint`. Guards against stale writes or late-arriving updates across worker partitions. If a write conflicts with an unexpected version, an `OptimisticLockException` is thrown.
|
|
171
361
|
|
|
172
362
|
---
|
|
173
363
|
|
|
174
|
-
##
|
|
364
|
+
## 7. Core Exception Types
|
|
365
|
+
|
|
366
|
+
All exceptions extend `SigloError` and can be imported from `@siglo/core`:
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
import {
|
|
370
|
+
OptimisticLockException,
|
|
371
|
+
DuplicateEventException,
|
|
372
|
+
ExpiredWaitException
|
|
373
|
+
} from '@siglo/core';
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### 1. `OptimisticLockException`
|
|
377
|
+
Thrown by `saveCheckpoint` when an update fails because the version in the database differs from `expectedVersion`:
|
|
378
|
+
- `err.stackId`: The workflow execution stack ID.
|
|
379
|
+
- `err.currentVersion`: The version currently in PostgreSQL.
|
|
380
|
+
- `err.expectedVersion`: The version expected by the writer.
|
|
381
|
+
|
|
382
|
+
### 2. `DuplicateEventException`
|
|
383
|
+
Thrown by `withWorkflowLock` when another worker currently holds the workflow lock, or by `claimWaitRecord` when a wait record is already claimed or resumed:
|
|
384
|
+
- `err.correlationKey` / `err.stackId`: The resource key that could not be locked.
|
|
385
|
+
|
|
386
|
+
### 3. `ExpiredWaitException`
|
|
387
|
+
Thrown by `claimWaitRecord` when attempting to claim a wait record whose `expires_at` timestamp has passed and `allowExpired` is not set:
|
|
388
|
+
- `err.correlationKey`: The wait record correlation key.
|
|
389
|
+
- `err.expiresAt`: Expiration timestamp in milliseconds.
|
|
390
|
+
- `err.eventName`: Name of the expected event.
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
394
|
+
## 8. Complete Production Example
|
|
395
|
+
|
|
396
|
+
Below is a complete, production-ready example demonstrating connection pool configuration, automatic schema initialization, workflow definition, idempotency guards, OCC error handling, and scheduled retention cleanup.
|
|
175
397
|
|
|
176
398
|
```typescript
|
|
177
399
|
import { Pool } from 'pg';
|
|
178
|
-
import { Step, flow } from '@siglo/core';
|
|
400
|
+
import { Step, flow, OptimisticLockException, DuplicateEventException } from '@siglo/core';
|
|
179
401
|
import { PostgresStorageAdapter, PostgresIdempotencyStore } from '@siglo/postgres';
|
|
180
402
|
|
|
181
403
|
// 1. Initialize PostgreSQL Connection Pool
|
|
@@ -183,78 +405,134 @@ const pool = new Pool({
|
|
|
183
405
|
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/orders_db',
|
|
184
406
|
max: 20,
|
|
185
407
|
idleTimeoutMillis: 30000,
|
|
408
|
+
connectionTimeoutMillis: 5000,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
pool.on('error', (err) => {
|
|
412
|
+
console.error('Unexpected idle PostgreSQL client error:', err);
|
|
186
413
|
});
|
|
187
414
|
|
|
188
415
|
// 2. Initialize and Register Storage Adapter
|
|
189
416
|
const storageAdapter = new PostgresStorageAdapter({
|
|
190
417
|
client: pool,
|
|
418
|
+
checkpointsTable: 'siglo_checkpoints',
|
|
419
|
+
eventsTable: 'siglo_context_events',
|
|
420
|
+
waitRecordsTable: 'siglo_wait_records',
|
|
191
421
|
recordContextChanges: true,
|
|
192
422
|
});
|
|
193
|
-
await storageAdapter.ensureTables();
|
|
194
|
-
Step.useStorage(storageAdapter);
|
|
195
423
|
|
|
196
424
|
// 3. Initialize Idempotency Store
|
|
197
425
|
const idempotencyStore = new PostgresIdempotencyStore({
|
|
198
426
|
client: pool,
|
|
427
|
+
tableName: 'siglo_idempotency',
|
|
199
428
|
defaultTtlMs: 86400000, // 24 hours
|
|
200
429
|
});
|
|
201
|
-
await idempotencyStore.ensureTable();
|
|
202
430
|
|
|
203
|
-
// 4.
|
|
204
|
-
|
|
205
|
-
|
|
431
|
+
// 4. Schema Initialization on Application Startup
|
|
432
|
+
export async function initializeDatabase() {
|
|
433
|
+
await storageAdapter.ensureTables();
|
|
434
|
+
await idempotencyStore.ensureTable();
|
|
435
|
+
console.log('PostgreSQL workflow schemas initialized successfully.');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Register global storage adapter
|
|
439
|
+
Step.useStorage(storageAdapter);
|
|
440
|
+
|
|
441
|
+
// 5. Define Production Workflow Steps
|
|
442
|
+
interface OrderInput {
|
|
443
|
+
orderId: string;
|
|
206
444
|
accountId: string;
|
|
207
|
-
|
|
445
|
+
amountCents: number;
|
|
208
446
|
}
|
|
209
447
|
|
|
210
|
-
const
|
|
211
|
-
//
|
|
212
|
-
return {
|
|
448
|
+
const debitAccount = Step.create('debitAccount', async (param: OrderInput) => {
|
|
449
|
+
// Execute debit operation
|
|
450
|
+
return {
|
|
451
|
+
debitReference: `dr_${param.orderId}`,
|
|
452
|
+
debitedAmount: param.amountCents,
|
|
453
|
+
};
|
|
213
454
|
});
|
|
214
455
|
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
456
|
+
const emitReceipt = Step.create('emitReceipt', async (param: any) => {
|
|
457
|
+
return {
|
|
458
|
+
receiptNumber: `rcpt_${param.orderId}`,
|
|
459
|
+
completedAt: new Date().toISOString(),
|
|
460
|
+
};
|
|
218
461
|
});
|
|
219
462
|
|
|
220
|
-
const
|
|
463
|
+
const paymentWorkflow = flow(debitAccount, emitReceipt);
|
|
221
464
|
|
|
222
|
-
//
|
|
223
|
-
async function
|
|
224
|
-
const idempKey = `
|
|
225
|
-
|
|
465
|
+
// 6. Execute Workflow with Distributed Idempotency and Workflow Lock
|
|
466
|
+
export async function processOrder(input: OrderInput) {
|
|
467
|
+
const idempKey = `order:payment:${input.orderId}`;
|
|
468
|
+
|
|
469
|
+
// Attempt 60-second atomic claim lock
|
|
470
|
+
const claimStatus = await idempotencyStore.claim(idempKey, 60000);
|
|
226
471
|
|
|
227
472
|
if (claimStatus === 'completed') {
|
|
228
|
-
console.log(
|
|
473
|
+
console.log(`Order ${input.orderId} already completed. Returning cached result.`);
|
|
229
474
|
return await idempotencyStore.getResult(idempKey);
|
|
230
475
|
}
|
|
231
476
|
|
|
232
477
|
if (claimStatus === 'in-progress') {
|
|
233
|
-
throw new Error(`
|
|
478
|
+
throw new Error(`Order ${input.orderId} is currently being processed by another worker.`);
|
|
234
479
|
}
|
|
235
480
|
|
|
236
481
|
try {
|
|
237
|
-
|
|
238
|
-
|
|
482
|
+
// Execute inside distributed advisory workflow lock
|
|
483
|
+
const stackId = `stack_${input.orderId}`;
|
|
484
|
+
const output = await storageAdapter.withWorkflowLock(stackId, async () => {
|
|
485
|
+
return await paymentWorkflow.run(input, {
|
|
486
|
+
namespace: 'ecommerce-prod',
|
|
487
|
+
});
|
|
239
488
|
});
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
489
|
+
|
|
490
|
+
const finalResult = {
|
|
491
|
+
orderId: input.orderId,
|
|
492
|
+
debitReference: output.debitReference,
|
|
493
|
+
receiptNumber: output.receiptNumber,
|
|
494
|
+
completedAt: output.completedAt,
|
|
244
495
|
};
|
|
245
|
-
|
|
246
|
-
|
|
496
|
+
|
|
497
|
+
// Complete idempotency record with final payload
|
|
498
|
+
await idempotencyStore.complete(idempKey, finalResult);
|
|
499
|
+
return finalResult;
|
|
247
500
|
} catch (err) {
|
|
501
|
+
// Clear in-flight idempotency claim so retries are permitted
|
|
248
502
|
await idempotencyStore.fail(idempKey, err);
|
|
503
|
+
|
|
504
|
+
if (err instanceof OptimisticLockException) {
|
|
505
|
+
console.error(`OCC Conflict on stack ${err.stackId}: expected v${err.expectedVersion}, got v${err.currentVersion}`);
|
|
506
|
+
} else if (err instanceof DuplicateEventException) {
|
|
507
|
+
console.warn(`Concurrent execution conflict on lock ${err.correlationKey}`);
|
|
508
|
+
}
|
|
509
|
+
|
|
249
510
|
throw err;
|
|
250
511
|
}
|
|
251
512
|
}
|
|
252
513
|
|
|
253
|
-
//
|
|
254
|
-
async function
|
|
255
|
-
|
|
256
|
-
|
|
514
|
+
// 7. Scheduled Maintenance & Retention Cleanup
|
|
515
|
+
export async function runNightlyRetentionCleanup() {
|
|
516
|
+
console.log('Starting automated PostgreSQL workflow data retention cleanup...');
|
|
517
|
+
|
|
518
|
+
const prunedCheckpoints = await storageAdapter.cleanupOldCheckpoints(14); // 14-day checkpoint retention
|
|
519
|
+
const prunedEvents = await storageAdapter.cleanupOldEvents(30); // 30-day audit log retention
|
|
520
|
+
const prunedWaits = await storageAdapter.cleanupOldWaitRecords(14); // 14-day wait record retention
|
|
521
|
+
const prunedExpiredCheckpoints = await storageAdapter.cleanExpiredCheckpoints();
|
|
257
522
|
const prunedIdemp = await idempotencyStore.cleanupExpired();
|
|
258
|
-
|
|
523
|
+
|
|
524
|
+
console.log(`Maintenance Summary:
|
|
525
|
+
- Deleted Checkpoints: ${prunedCheckpoints}
|
|
526
|
+
- Expired Checkpoints: ${prunedExpiredCheckpoints}
|
|
527
|
+
- Deleted Context Events: ${prunedEvents}
|
|
528
|
+
- Deleted Wait Records: ${prunedWaits}
|
|
529
|
+
- Expired Idempotency Keys: ${prunedIdemp}`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// 8. Graceful Shutdown
|
|
533
|
+
export async function shutdown() {
|
|
534
|
+
await pool.end();
|
|
535
|
+
console.log('PostgreSQL connection pool closed.');
|
|
259
536
|
}
|
|
260
537
|
```
|
|
538
|
+
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@siglo/postgres",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "PostgreSQL persistence adapter for Siglo TypeScript workflows with durable checkpoints, event auditing, and idempotency.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"exports": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"bugs": {
|
|
38
38
|
"url": "https://github.com/jsoftgem/siglo/issues"
|
|
39
39
|
},
|
|
40
|
-
"homepage": "https://github.com/jsoftgem/siglo#readme",
|
|
40
|
+
"homepage": "https://github.com/jsoftgem/siglo/tree/main/packages/postgres#readme",
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@siglo/core": "^1.0.0"
|
|
43
43
|
},
|
|
@@ -57,5 +57,16 @@
|
|
|
57
57
|
"mocha": "^12.0.3",
|
|
58
58
|
"tsx": "^4.23.15",
|
|
59
59
|
"typescript": "~5.7.0"
|
|
60
|
-
}
|
|
60
|
+
},
|
|
61
|
+
"keywords": [
|
|
62
|
+
"siglo",
|
|
63
|
+
"workflow",
|
|
64
|
+
"typescript",
|
|
65
|
+
"postgres",
|
|
66
|
+
"postgresql",
|
|
67
|
+
"persistence",
|
|
68
|
+
"checkpoint",
|
|
69
|
+
"durable-execution",
|
|
70
|
+
"idempotency"
|
|
71
|
+
]
|
|
61
72
|
}
|