@flowcore/data-pump 0.22.1 → 0.23.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/CHANGELOG.md +28 -0
- package/README.md +313 -191
- package/dist/mod.d.ts +147 -16
- package/dist/mod.js +623 -170
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,34 +11,30 @@ real-time event processing with automatic retry, buffering, and state management
|
|
|
11
11
|
import { FlowcoreDataPump } from "@flowcore/data-pump"
|
|
12
12
|
|
|
13
13
|
const dataPump = FlowcoreDataPump.create({
|
|
14
|
-
//
|
|
15
|
-
// there are two ways to authenticate. API key and OIDC/Bearer token.
|
|
14
|
+
// The identity must have permission to list buckets and read events.
|
|
16
15
|
auth: {
|
|
17
|
-
apiKey:
|
|
18
|
-
apiKeyId: "your-api-key-id",
|
|
16
|
+
apiKey: process.env.FLOWCORE_API_KEY!,
|
|
19
17
|
},
|
|
20
18
|
dataSource: {
|
|
21
|
-
tenant: "your-tenant-name",
|
|
22
|
-
dataCore: "your-data-core",
|
|
23
|
-
flowType: "your-flow-type",
|
|
24
|
-
eventTypes: ["event-type-1", "event-type-2"
|
|
19
|
+
tenant: "your-tenant-name",
|
|
20
|
+
dataCore: "your-data-core",
|
|
21
|
+
flowType: "your-flow-type",
|
|
22
|
+
eventTypes: ["event-type-1", "event-type-2"],
|
|
25
23
|
},
|
|
26
|
-
noTranslation: false, // if true (the data core, flow types, and event types) names will not be translated to ids. Use this for performance reasons.
|
|
27
24
|
stateManager: {
|
|
28
|
-
getState: () =>
|
|
29
|
-
setState: (state) =>
|
|
25
|
+
getState: () => loadState(),
|
|
26
|
+
setState: (state) => saveState(state),
|
|
30
27
|
},
|
|
31
28
|
processor: {
|
|
32
|
-
//
|
|
29
|
+
concurrency: 10, // Batch size passed to one handler invocation
|
|
33
30
|
handler: async (events) => {
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
for (const event of events) {
|
|
32
|
+
await processIdempotently(event)
|
|
33
|
+
}
|
|
36
34
|
},
|
|
37
35
|
},
|
|
38
36
|
notifier: { type: "websocket" },
|
|
39
|
-
|
|
40
|
-
directMode: false, // To interact with the Flowcore API more directly. This is a dedicated cluster feature.
|
|
41
|
-
bufferSize: 100,
|
|
37
|
+
bufferSize: 500,
|
|
42
38
|
logger: {
|
|
43
39
|
debug: (msg) => console.log(`[DEBUG] ${msg}`),
|
|
44
40
|
info: (msg) => console.log(`[INFO] ${msg}`),
|
|
@@ -47,7 +43,10 @@ const dataPump = FlowcoreDataPump.create({
|
|
|
47
43
|
},
|
|
48
44
|
})
|
|
49
45
|
|
|
50
|
-
|
|
46
|
+
// The callback form runs in the background and enables fetch-loop self-healing.
|
|
47
|
+
await dataPump.start((error) => {
|
|
48
|
+
if (error) console.error("Data Pump stopped with an error", error)
|
|
49
|
+
})
|
|
51
50
|
```
|
|
52
51
|
|
|
53
52
|
## Installation
|
|
@@ -83,22 +82,19 @@ Events are organized in **hourly time buckets** using the format `yyyyMMddHH0000
|
|
|
83
82
|
|
|
84
83
|
### **State Management**
|
|
85
84
|
|
|
86
|
-
The pump
|
|
85
|
+
The pump persists a conservative processing frontier using a time bucket and event ID:
|
|
87
86
|
|
|
88
87
|
```typescript
|
|
89
88
|
// Current position in event stream
|
|
90
89
|
{
|
|
91
|
-
timeBucket: "20240315140000",
|
|
92
|
-
eventId: "abc-123-def-456"
|
|
90
|
+
timeBucket: "20240315140000",
|
|
91
|
+
eventId: "abc-123-def-456"
|
|
93
92
|
}
|
|
94
93
|
```
|
|
95
94
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
- **Horizontal scaling**: Multiple instances can coordinate using shared database state
|
|
100
|
-
- **Historical processing**: Start from any point in time (hours, days, months ago)
|
|
101
|
-
- **Deployment safety**: Updates don't lose processing progress
|
|
95
|
+
The fetch position can run ahead of this persisted frontier. When events remain buffered, the saved event ID is based on
|
|
96
|
+
the earliest remaining event rather than simply the last handler to finish. After a crash, the pump may therefore replay
|
|
97
|
+
events. Consumers must use `eventId` as a durable idempotency key.
|
|
102
98
|
|
|
103
99
|
### **Event Lifecycle & Processing Modes**
|
|
104
100
|
|
|
@@ -144,50 +140,51 @@ Two fundamental processing patterns:
|
|
|
144
140
|
#### **Live Mode**
|
|
145
141
|
|
|
146
142
|
- **When**: `stateManager.getState()` returns `null`
|
|
147
|
-
- **Behavior**:
|
|
143
|
+
- **Behavior**: Start in the current hour after a time-based event ID representing now, then process newly stored events
|
|
148
144
|
- **Use case**: Production event processing, real-time analytics
|
|
149
145
|
|
|
146
|
+
Returning `null` does not replay events from earlier in the current hour. Supply an explicit state for a backfill or when
|
|
147
|
+
creating a projection from retained history.
|
|
148
|
+
|
|
150
149
|
#### **Historical Mode**
|
|
151
150
|
|
|
152
151
|
- **When**: `stateManager.getState()` returns `{ timeBucket, eventId }`
|
|
153
152
|
- **Behavior**: Process events from specific point in time
|
|
154
153
|
- **Use case**: Backfill data, debugging, data migration, replaying scenarios
|
|
155
154
|
|
|
156
|
-
###
|
|
155
|
+
### **Batch Size and Application Concurrency**
|
|
157
156
|
|
|
158
|
-
|
|
157
|
+
In version 0.22.x, `processor.concurrency` controls how many events are reserved and passed to one handler invocation. It
|
|
158
|
+
does not start that many handler invocations in parallel:
|
|
159
159
|
|
|
160
160
|
```typescript
|
|
161
161
|
processor: {
|
|
162
|
-
concurrency: 5,
|
|
162
|
+
concurrency: 5,
|
|
163
163
|
handler: async (events) => {
|
|
164
|
-
// This batch
|
|
165
|
-
//
|
|
164
|
+
// This batch contains up to five events.
|
|
165
|
+
// Add parallelism here only when ordering and dependencies allow it.
|
|
166
|
+
await Promise.all(events.map(processIdempotently))
|
|
166
167
|
}
|
|
167
168
|
}
|
|
168
169
|
```
|
|
169
170
|
|
|
170
|
-
|
|
171
|
+
The batch is acknowledged only after the complete handler resolves. If some events commit before a later event throws,
|
|
172
|
+
the whole batch can be delivered again.
|
|
171
173
|
|
|
172
|
-
|
|
173
|
-
- **Lower concurrency**: More controlled, better for external API limits
|
|
174
|
-
- **Optimal range**: Usually 5-20 for most applications
|
|
174
|
+
### **Failure Handling and Retries**
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
Automatic resilience for production systems:
|
|
176
|
+
Handler retries are driven by the acknowledgment timeout:
|
|
179
177
|
|
|
180
178
|
```
|
|
181
|
-
|
|
182
|
-
↓ ↓ ↓ ↓ ↓
|
|
183
|
-
Log error Log retry Log retry Log retry failedHandler()
|
|
179
|
+
Reserve → Handler throws → Reservation times out → Reopen → Reserve again
|
|
184
180
|
```
|
|
185
181
|
|
|
186
|
-
|
|
182
|
+
`achknowledgeTimeoutMs` is a fixed delay; per-event redelivery does not use exponential backoff. With the default
|
|
183
|
+
`maxRedeliveryCount: 3`, an event can be reserved four times: the initial delivery plus three redeliveries. Set the value
|
|
184
|
+
to `-1` only when unlimited redelivery is intentional.
|
|
187
185
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
- **Exponential backoff**: Automatic delays between retries
|
|
186
|
+
Exponential backoff from one to thirty seconds is used for failed fetch loops, process loops, leader pumps, and cluster
|
|
187
|
+
reconnections.
|
|
191
188
|
|
|
192
189
|
## Usage Patterns
|
|
193
190
|
|
|
@@ -213,12 +210,11 @@ const dataPump = FlowcoreDataPump.create({
|
|
|
213
210
|
processor: {
|
|
214
211
|
concurrency: 5,
|
|
215
212
|
handler: async (events) => {
|
|
216
|
-
// You only write business logic here
|
|
217
213
|
for (const event of events) {
|
|
218
|
-
await
|
|
214
|
+
await processIdempotently(event)
|
|
219
215
|
}
|
|
220
|
-
//
|
|
221
|
-
//
|
|
216
|
+
// The complete batch is acknowledged after this handler resolves.
|
|
217
|
+
// If it throws, the reservation reopens after achknowledgeTimeoutMs.
|
|
222
218
|
},
|
|
223
219
|
failedHandler: async (failedEvents) => {
|
|
224
220
|
// Handle events that permanently failed after all retries
|
|
@@ -229,14 +225,16 @@ const dataPump = FlowcoreDataPump.create({
|
|
|
229
225
|
maxRedeliveryCount: 3,
|
|
230
226
|
})
|
|
231
227
|
|
|
232
|
-
await dataPump.start()
|
|
228
|
+
await dataPump.start((error) => {
|
|
229
|
+
if (error) console.error("Data Pump stopped", error)
|
|
230
|
+
})
|
|
233
231
|
```
|
|
234
232
|
|
|
235
233
|
### Pull Mode (Manual Lifecycle Control)
|
|
236
234
|
|
|
237
|
-
**For advanced scenarios** - You control
|
|
235
|
+
**For advanced scenarios** - You control reservation and acknowledgment manually.
|
|
238
236
|
|
|
239
|
-
- **You handle**: Reserve → Process → Acknowledge
|
|
237
|
+
- **You handle**: Reserve → Process → Acknowledge, leave reserved for retry, or fail terminally
|
|
240
238
|
- **Pump provides**: Raw event access and buffer management
|
|
241
239
|
- **Use when**: Complex error handling, partial batch failures, or custom acknowledgment logic
|
|
242
240
|
|
|
@@ -254,50 +252,29 @@ const dataPump = FlowcoreDataPump.create({
|
|
|
254
252
|
// ❌ No processor = manual mode
|
|
255
253
|
})
|
|
256
254
|
|
|
257
|
-
await dataPump.start()
|
|
255
|
+
await dataPump.start((error) => {
|
|
256
|
+
if (error) console.error("Data Pump stopped", error)
|
|
257
|
+
})
|
|
258
258
|
|
|
259
259
|
// You manually control the entire event lifecycle
|
|
260
260
|
while (dataPump.isRunning) {
|
|
261
|
-
|
|
262
|
-
// 1️⃣ YOU manually reserve events from buffer
|
|
263
|
-
const events = await dataPump.reserve(10)
|
|
264
|
-
|
|
265
|
-
if (events.length === 0) {
|
|
266
|
-
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
267
|
-
continue
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// 2️⃣ YOU handle business logic with custom error handling
|
|
271
|
-
const results = await Promise.allSettled(events.map((event) => processEvent(event)))
|
|
261
|
+
const events = await dataPump.reserve(10)
|
|
272
262
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (result.status === "fulfilled") {
|
|
280
|
-
successfulIds.push(eventId)
|
|
281
|
-
} else {
|
|
282
|
-
failedIds.push(eventId)
|
|
283
|
-
}
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
// 4️⃣ YOU manually acknowledge successful events (removes from buffer)
|
|
287
|
-
if (successfulIds.length > 0) {
|
|
288
|
-
await dataPump.acknowledge(successfulIds)
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// 5️⃣ YOU manually mark failed events for retry
|
|
292
|
-
if (failedIds.length > 0) {
|
|
293
|
-
await dataPump.fail(failedIds)
|
|
263
|
+
for (const event of events) {
|
|
264
|
+
try {
|
|
265
|
+
await processIdempotently(event)
|
|
266
|
+
await dataPump.acknowledge([event.eventId])
|
|
267
|
+
} catch (error) {
|
|
268
|
+
console.warn("Leaving event reserved for timeout-based redelivery", event.eventId, error)
|
|
294
269
|
}
|
|
295
|
-
} catch (error) {
|
|
296
|
-
console.error("Processing error:", error)
|
|
297
270
|
}
|
|
298
271
|
}
|
|
299
272
|
```
|
|
300
273
|
|
|
274
|
+
`fail(eventIds)` is terminal: it removes matching events from the buffer and invokes `failedHandler` when configured. It
|
|
275
|
+
does **not** schedule a retry. Leave a reservation unresolved to make it eligible for redelivery after the acknowledgment
|
|
276
|
+
timeout.
|
|
277
|
+
|
|
301
278
|
### Which Mode Should You Use?
|
|
302
279
|
|
|
303
280
|
| Scenario | Recommended Mode | Why |
|
|
@@ -316,18 +293,17 @@ while (dataPump.isRunning) {
|
|
|
316
293
|
|
|
317
294
|
```typescript
|
|
318
295
|
auth: {
|
|
319
|
-
apiKey:
|
|
320
|
-
apiKeyId: "your-api-key-id"
|
|
296
|
+
apiKey: process.env.FLOWCORE_API_KEY!
|
|
321
297
|
}
|
|
322
298
|
```
|
|
323
299
|
|
|
324
|
-
|
|
325
|
-
|
|
300
|
+
Current `fc_{id}_{secret}` keys contain their key ID. `apiKeyId` remains available for old keys but is deprecated. The
|
|
301
|
+
identity needs IAM permission to list time buckets and read events for the selected resources.
|
|
326
302
|
|
|
327
303
|
### OIDC/Bearer Token Authentication
|
|
328
304
|
|
|
329
305
|
```typescript
|
|
330
|
-
import { oidcClient } from "@flowcore/oidc-client"
|
|
306
|
+
import { oidcClient } from "@flowcore/sdk-oidc-client"
|
|
331
307
|
|
|
332
308
|
const oidc = oidcClient({
|
|
333
309
|
clientId: "your-client-id",
|
|
@@ -335,15 +311,14 @@ const oidc = oidcClient({
|
|
|
335
311
|
})
|
|
336
312
|
|
|
337
313
|
auth: {
|
|
338
|
-
getBearerToken
|
|
339
|
-
;() => oidc.getToken().then((token) => token.accessToken)
|
|
314
|
+
getBearerToken: () => oidc.getToken().then((token) => token.accessToken)
|
|
340
315
|
}
|
|
341
316
|
```
|
|
342
317
|
|
|
343
318
|
## State Management
|
|
344
319
|
|
|
345
|
-
The state manager
|
|
346
|
-
|
|
320
|
+
The state manager stores a conservative processing frontier. It supports recovery, but it does not provide exactly-once
|
|
321
|
+
processing. A crash between a business side effect and acknowledgment can cause replay, so handlers must be idempotent.
|
|
347
322
|
|
|
348
323
|
### Understanding State
|
|
349
324
|
|
|
@@ -360,7 +335,7 @@ interface FlowcoreDataPumpState {
|
|
|
360
335
|
|
|
361
336
|
**Return Values:**
|
|
362
337
|
|
|
363
|
-
- `null` → Start in
|
|
338
|
+
- `null` → Start in the current hour after an event ID representing the current time
|
|
364
339
|
- `{ timeBucket, eventId }` → Start from **specific position** (historical processing)
|
|
365
340
|
|
|
366
341
|
### Precise Positioning with TimeUuid
|
|
@@ -375,16 +350,13 @@ const eventId = TimeUuid.fromDate(new Date("2024-01-01T12:30:00Z")).toString()
|
|
|
375
350
|
|
|
376
351
|
// Start processing from timestamp (doesn't need to match existing event)
|
|
377
352
|
const stateManager = {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const timestamp = TimeUuid.fromString(state.eventId).getDate()
|
|
386
|
-
console.log(`Processed up to: ${timestamp.toISOString()}`)
|
|
387
|
-
},
|
|
353
|
+
getState: () => ({
|
|
354
|
+
timeBucket: "20240101120000", // Hour bucket: 2024-01-01 12:00
|
|
355
|
+
eventId, // Start from first event AFTER 12:30:00
|
|
356
|
+
}),
|
|
357
|
+
setState: (state) => {
|
|
358
|
+
const timestamp = state.eventId ? TimeUuid.fromString(state.eventId).getDate() : undefined
|
|
359
|
+
console.log("Saved processing frontier", state.timeBucket, timestamp?.toISOString())
|
|
388
360
|
},
|
|
389
361
|
}
|
|
390
362
|
// Other useful TimeUuid methods:
|
|
@@ -434,28 +406,15 @@ stateManager: {
|
|
|
434
406
|
**Best for**: Single instance deployments, simple persistence needs
|
|
435
407
|
|
|
436
408
|
```typescript
|
|
437
|
-
import { readFileSync, writeFileSync } from
|
|
409
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
438
410
|
|
|
439
411
|
stateManager: {
|
|
440
412
|
getState: () => {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const state = JSON.parse(data);
|
|
444
|
-
console.log('Resuming from saved state:', state);
|
|
445
|
-
return state;
|
|
446
|
-
} catch (error) {
|
|
447
|
-
console.log('No previous state found, starting fresh');
|
|
448
|
-
return null; // Start in live mode
|
|
449
|
-
}
|
|
413
|
+
if (!existsSync("pump-state.json")) return null
|
|
414
|
+
return JSON.parse(readFileSync("pump-state.json", "utf8"))
|
|
450
415
|
},
|
|
451
416
|
setState: (state) => {
|
|
452
|
-
|
|
453
|
-
writeFileSync('pump-state.json', JSON.stringify(state, null, 2));
|
|
454
|
-
console.log('State saved:', state);
|
|
455
|
-
} catch (error) {
|
|
456
|
-
console.error('Failed to save state:', error);
|
|
457
|
-
// Consider throwing to stop pump if state saving is critical
|
|
458
|
-
}
|
|
417
|
+
writeFileSync("pump-state.json", JSON.stringify(state, null, 2))
|
|
459
418
|
}
|
|
460
419
|
}
|
|
461
420
|
```
|
|
@@ -479,9 +438,9 @@ stateManager: {
|
|
|
479
438
|
```sql
|
|
480
439
|
-- Example table schema
|
|
481
440
|
CREATE TABLE flowcore_pump_state (
|
|
482
|
-
id VARCHAR(50) PRIMARY KEY,
|
|
441
|
+
id VARCHAR(50) PRIMARY KEY, -- Logical consumer identifier
|
|
483
442
|
time_bucket VARCHAR(14) NOT NULL, -- "yyyyMMddHH0000"
|
|
484
|
-
event_id VARCHAR(255),
|
|
443
|
+
event_id VARCHAR(255), -- Conservative processing frontier
|
|
485
444
|
updated_at TIMESTAMP DEFAULT NOW()
|
|
486
445
|
);
|
|
487
446
|
```
|
|
@@ -509,9 +468,7 @@ stateManager: {
|
|
|
509
468
|
|
|
510
469
|
} catch (error) {
|
|
511
470
|
console.error('Failed to load state from database:', error);
|
|
512
|
-
//
|
|
513
|
-
return null; // Start fresh if DB is down
|
|
514
|
-
// throw error; // Or fail fast if state is critical
|
|
471
|
+
throw error; // Do not silently jump to "now" when durable state is unavailable.
|
|
515
472
|
}
|
|
516
473
|
},
|
|
517
474
|
|
|
@@ -537,10 +494,10 @@ stateManager: {
|
|
|
537
494
|
**✅ Benefits:**
|
|
538
495
|
|
|
539
496
|
- Survives crashes and restarts
|
|
540
|
-
- Supports
|
|
497
|
+
- Supports shared state for cluster mode
|
|
541
498
|
- Atomic updates with transactions
|
|
542
499
|
- Can be backed up with your database
|
|
543
|
-
- Enables horizontal scaling
|
|
500
|
+
- Enables coordinated horizontal scaling
|
|
544
501
|
|
|
545
502
|
**⚠️ Considerations:**
|
|
546
503
|
|
|
@@ -548,62 +505,47 @@ stateManager: {
|
|
|
548
505
|
- Network latency on state updates
|
|
549
506
|
- Requires error handling strategy
|
|
550
507
|
|
|
551
|
-
###
|
|
508
|
+
### Independent Consumers and Shared Clusters
|
|
552
509
|
|
|
553
|
-
|
|
510
|
+
Use separate state keys when instances intentionally process different event selections as independent consumers:
|
|
554
511
|
|
|
555
512
|
```typescript
|
|
556
|
-
|
|
557
|
-
const instanceId = `processor-${process.env.INSTANCE_ID}`;
|
|
513
|
+
const consumerId = process.env.CONSUMER_ID!;
|
|
558
514
|
|
|
559
515
|
stateManager: {
|
|
560
516
|
getState: async () => {
|
|
561
517
|
const result = await db.query(
|
|
562
518
|
'SELECT time_bucket, event_id FROM flowcore_pump_state WHERE id = ?',
|
|
563
|
-
[
|
|
519
|
+
[consumerId]
|
|
564
520
|
);
|
|
565
521
|
return result[0] || null;
|
|
566
522
|
},
|
|
567
523
|
setState: async (state) => {
|
|
568
524
|
await db.query(
|
|
569
525
|
'INSERT OR REPLACE INTO flowcore_pump_state (id, time_bucket, event_id) VALUES (?, ?, ?)',
|
|
570
|
-
[
|
|
526
|
+
[consumerId, state.timeBucket, state.eventId]
|
|
571
527
|
);
|
|
572
528
|
}
|
|
573
529
|
}
|
|
574
530
|
```
|
|
575
531
|
|
|
576
|
-
|
|
532
|
+
Do not use separate state rows for replicas that are meant to share one workload. Use `FlowcoreDataPumpCluster` with one
|
|
533
|
+
shared durable state manager so only the elected leader fetches and advances the logical checkpoint.
|
|
577
534
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
let eventCount = 0;
|
|
581
|
-
const CHECKPOINT_INTERVAL = 100;
|
|
582
|
-
|
|
583
|
-
stateManager: {
|
|
584
|
-
getState: () => loadStateFromFile(),
|
|
585
|
-
setState: (state) => {
|
|
586
|
-
eventCount++;
|
|
587
|
-
// Only save every 100 events to reduce I/O
|
|
588
|
-
if (eventCount % CHECKPOINT_INTERVAL === 0) {
|
|
589
|
-
saveStateToFile(state);
|
|
590
|
-
console.log(`Checkpoint saved after ${eventCount} events`);
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
```
|
|
535
|
+
Persist every state update unless your storage adapter can prove that coalescing writes cannot advance beyond unfinished
|
|
536
|
+
work. Skipping arbitrary checkpoint writes can increase replay and make the stored frontier misleading.
|
|
595
537
|
|
|
596
538
|
### Choosing a State Manager
|
|
597
539
|
|
|
598
|
-
| Scenario | Recommended
|
|
599
|
-
| ------------------------------- |
|
|
600
|
-
| **Local development** | Memory
|
|
601
|
-
| **Testing/CI** | Memory
|
|
602
|
-
| **Single instance, simple** | File-based
|
|
603
|
-
| **Production, single instance** | Database
|
|
604
|
-
| **
|
|
605
|
-
| **
|
|
606
|
-
| **Mission-critical** | Database + Monitoring
|
|
540
|
+
| Scenario | Recommended | Reason |
|
|
541
|
+
| ------------------------------- | ----------------------- | ---------------------------------- |
|
|
542
|
+
| **Local development** | Memory | Fast iteration, no setup |
|
|
543
|
+
| **Testing/CI** | Memory | Clean state per test run |
|
|
544
|
+
| **Single instance, simple** | File-based | Persistence without DB complexity |
|
|
545
|
+
| **Production, single instance** | Database | Reliability and backup integration |
|
|
546
|
+
| **Independent consumers** | Database, separate keys | Separate selections and positions |
|
|
547
|
+
| **Shared worker cluster** | Database + cluster mode | One elected fetcher and checkpoint |
|
|
548
|
+
| **Mission-critical** | Database + Monitoring | Full observability stack |
|
|
607
549
|
|
|
608
550
|
## Notification Methods
|
|
609
551
|
|
|
@@ -617,6 +559,10 @@ notifier: {
|
|
|
617
559
|
}
|
|
618
560
|
```
|
|
619
561
|
|
|
562
|
+
WebSocket waits have a 20-second safety timeout. An event, connection error, abort signal, or timeout releases the wait so
|
|
563
|
+
the fetch loop can query durable storage again. Each cycle creates a fresh client, allowing the pump to recover from a
|
|
564
|
+
hung or half-open connection.
|
|
565
|
+
|
|
620
566
|
### NATS
|
|
621
567
|
|
|
622
568
|
For distributed systems with message queues:
|
|
@@ -635,10 +581,111 @@ Simple polling mechanism:
|
|
|
635
581
|
```typescript
|
|
636
582
|
notifier: {
|
|
637
583
|
type: "poller",
|
|
638
|
-
intervalMs:
|
|
584
|
+
intervalMs: 1000
|
|
639
585
|
}
|
|
640
586
|
```
|
|
641
587
|
|
|
588
|
+
> **Version 0.22.x caveat:** The implementation currently uses `Math.min(intervalMs, 1000)`. Values above one second
|
|
589
|
+
> therefore still wake after one second. Account for the API traffic until this behavior is corrected.
|
|
590
|
+
|
|
591
|
+
Notifications only wake the pump. Flowcore event history and the persisted state remain the durable recovery mechanism.
|
|
592
|
+
|
|
593
|
+
## Cluster Mode
|
|
594
|
+
|
|
595
|
+
`FlowcoreDataPumpCluster` scales handler execution while keeping one logical fetcher and one shared checkpoint. Every
|
|
596
|
+
replica registers with a coordinator and participates in lease election. The leader runs `FlowcoreDataPump`; other
|
|
597
|
+
instances process batches distributed by the leader.
|
|
598
|
+
|
|
599
|
+
Cluster mode requires a durable state manager and a user-provided `FlowcoreDataPumpCoordinator`. The package defines the
|
|
600
|
+
coordinator contract but does not ship a production implementation:
|
|
601
|
+
|
|
602
|
+
```typescript
|
|
603
|
+
interface FlowcoreDataPumpCoordinator {
|
|
604
|
+
acquireLease(instanceId: string, key: string, ttlMs: number): Promise<boolean>
|
|
605
|
+
renewLease(instanceId: string, key: string, ttlMs: number): Promise<boolean>
|
|
606
|
+
releaseLease(instanceId: string, key: string): Promise<void>
|
|
607
|
+
register(instanceId: string, address: string): Promise<void>
|
|
608
|
+
heartbeat(instanceId: string): Promise<void>
|
|
609
|
+
unregister(instanceId: string): Promise<void>
|
|
610
|
+
getInstances(staleThresholdMs: number): Promise<
|
|
611
|
+
Array<{
|
|
612
|
+
instanceId: string
|
|
613
|
+
address: string
|
|
614
|
+
}>
|
|
615
|
+
>
|
|
616
|
+
}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Lease acquisition must be atomic. Renewal must succeed only for the current holder, and `getInstances()` must omit stale
|
|
620
|
+
registrations. The `integration/app` directory contains a PostgreSQL reference used by the Kubernetes integration suite.
|
|
621
|
+
|
|
622
|
+
### NATS Distribution
|
|
623
|
+
|
|
624
|
+
Setting `notifier.type` to `nats` also selects NATS request/reply for cluster event distribution:
|
|
625
|
+
|
|
626
|
+
```typescript
|
|
627
|
+
import { FlowcoreDataPumpCluster } from "@flowcore/data-pump"
|
|
628
|
+
|
|
629
|
+
const cluster = new FlowcoreDataPumpCluster({
|
|
630
|
+
auth: { apiKey: process.env.FLOWCORE_API_KEY! },
|
|
631
|
+
dataSource: {
|
|
632
|
+
tenant: "acme",
|
|
633
|
+
dataCore: "commerce",
|
|
634
|
+
flowType: "order.0",
|
|
635
|
+
eventTypes: ["order.placed.0"],
|
|
636
|
+
},
|
|
637
|
+
stateManager: sharedStateManager,
|
|
638
|
+
coordinator: postgresCoordinator,
|
|
639
|
+
notifier: {
|
|
640
|
+
type: "nats",
|
|
641
|
+
servers: [process.env.NATS_URL!],
|
|
642
|
+
},
|
|
643
|
+
clusterKey: "orders-projection-v1",
|
|
644
|
+
workerConcurrency: 10,
|
|
645
|
+
processor: {
|
|
646
|
+
handler: async (events) => {
|
|
647
|
+
for (const event of events) await processIdempotently(event)
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
await cluster.start()
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
All replicas join the `data-pump-workers` queue group, including the leader. A distribution request waits up to 30
|
|
656
|
+
seconds for a reply. If NATS distribution fails, the leader runs the handler locally. A worker may have committed before
|
|
657
|
+
its reply was lost, so this fallback preserves at-least-once rather than exactly-once behavior.
|
|
658
|
+
|
|
659
|
+
`clusterKey` scopes the NATS subject. In 0.22.x the internal leader lease key is fixed to
|
|
660
|
+
`flowcore-data-pump-leader`; it is not scoped by `clusterKey`. Namespace coordinator storage when unrelated logical
|
|
661
|
+
clusters share a database.
|
|
662
|
+
|
|
663
|
+
### WebSocket Distribution
|
|
664
|
+
|
|
665
|
+
All non-NATS cluster configurations use a WebSocket mesh. Each replica must:
|
|
666
|
+
|
|
667
|
+
1. Host a WebSocket server and pass accepted connections to `cluster.handleConnection()`.
|
|
668
|
+
2. Configure an `advertisedAddress` reachable by every peer.
|
|
669
|
+
3. Register and heartbeat through the shared coordinator.
|
|
670
|
+
|
|
671
|
+
The leader discovers live workers every ten seconds and sends complete batches round-robin. Connections use ping/pong
|
|
672
|
+
health checks, and each delivery has a 30-second acknowledgment timeout. If no worker is available, the leader processes
|
|
673
|
+
locally.
|
|
674
|
+
|
|
675
|
+
The default lease TTL is 30 seconds, renewal interval is 10 seconds, and heartbeat interval is 5 seconds. Tune those
|
|
676
|
+
values together. Cluster mode exposes the automatic processor model only; it does not expose `reserve()`,
|
|
677
|
+
`acknowledge()`, `fail()`, or `restart()`.
|
|
678
|
+
|
|
679
|
+
Stop a cluster gracefully so it can release its lease and unregister:
|
|
680
|
+
|
|
681
|
+
```typescript
|
|
682
|
+
process.on("SIGTERM", async () => {
|
|
683
|
+
await cluster.stop()
|
|
684
|
+
await db.end()
|
|
685
|
+
process.exit(0)
|
|
686
|
+
})
|
|
687
|
+
```
|
|
688
|
+
|
|
642
689
|
## ⚙️ Configuration Reference
|
|
643
690
|
|
|
644
691
|
| Option | Type | Default | Description |
|
|
@@ -647,16 +694,17 @@ notifier: {
|
|
|
647
694
|
| `dataSource` | `FlowcoreDataPumpDataSource` | **Required** | Data source configuration (tenant, dataCore, flowType, eventTypes) |
|
|
648
695
|
| `stateManager` | `FlowcoreDataPumpStateManager` | **Required** | State persistence configuration |
|
|
649
696
|
| `bufferSize` | `number` | `1000` | Maximum events to buffer in memory |
|
|
650
|
-
| `maxRedeliveryCount` | `number` | `3` |
|
|
651
|
-
| `achknowledgeTimeoutMs` | `number` | `5000` |
|
|
697
|
+
| `maxRedeliveryCount` | `number` | `3` | Redeliveries after the initial attempt; `-1` disables the cap |
|
|
698
|
+
| `achknowledgeTimeoutMs` | `number` | `5000` | Fixed timeout before an unresolved reservation reopens (spelling preserved by the public API) |
|
|
652
699
|
| `includeSensitiveData` | `boolean` | `false` | Include sensitive data in events |
|
|
653
|
-
| `processor` | `FlowcoreDataPumpProcessor` | `undefined` | Automatic processing configuration
|
|
700
|
+
| `processor` | `FlowcoreDataPumpProcessor` | `undefined` | Automatic processing configuration; `concurrency` is the handler batch size in 0.22.x |
|
|
654
701
|
| `notifier` | `FlowcoreDataPumpNotifierOptions` | `websocket` | Notification method configuration |
|
|
655
702
|
| `logger` | `FlowcoreLogger` | `undefined` | Custom logger implementation |
|
|
656
703
|
| `stopAt` | `Date` | `undefined` | Stop processing at specific date (for historical processing) |
|
|
657
704
|
| `baseUrlOverride` | `string` | `undefined` | Override Flowcore API base URL |
|
|
658
|
-
| `noTranslation` | `boolean` | `false` |
|
|
705
|
+
| `noTranslation` | `boolean` | `false` | Treat tenant, data core, flow type, and event type values as IDs and skip translation |
|
|
659
706
|
| `directMode` | `boolean` | `false` | Enables direct API execution mode, bypassing intermediary gateways; recommended for dedicated Flowcore clusters to reduce latency (often used with `noTranslation: true`) |
|
|
707
|
+
| `pulse` | `object` | `undefined` | Periodically send pump position, buffer, counters, and uptime to a Flowcore control-plane endpoint |
|
|
660
708
|
|
|
661
709
|
## 🔧 API Reference
|
|
662
710
|
|
|
@@ -724,31 +772,73 @@ if (dataPump.isRunning) {
|
|
|
724
772
|
console.log("Pump is running")
|
|
725
773
|
}
|
|
726
774
|
|
|
727
|
-
//
|
|
775
|
+
// Blocking: resolves when the fetch loop stops and rejects on a fetch error.
|
|
728
776
|
await dataPump.start()
|
|
729
777
|
|
|
730
|
-
//
|
|
778
|
+
// Background/self-healing: fetch errors retry with exponential backoff.
|
|
779
|
+
await dataPump.start((error) => {
|
|
780
|
+
if (error) console.error("Data Pump stopped", error)
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
// Stop immediately. The in-memory buffer is cleared rather than drained.
|
|
731
784
|
dataPump.stop()
|
|
732
785
|
|
|
733
|
-
// Restart from a specific position
|
|
734
|
-
// This is useful for backfill scenarios, error recovery, and dynamic repositioning
|
|
786
|
+
// Restart from a specific position. The bucket must contain exactly 14 digits.
|
|
735
787
|
dataPump.restart({
|
|
736
|
-
timeBucket: "20240101120000",
|
|
737
|
-
eventId: "specific-event-id",
|
|
788
|
+
timeBucket: "20240101120000",
|
|
789
|
+
eventId: "specific-event-id",
|
|
738
790
|
})
|
|
791
|
+
```
|
|
739
792
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
)
|
|
793
|
+
#### Pause and Resume Delivery
|
|
794
|
+
|
|
795
|
+
`pause()` stops delivery to the processor without stopping the pump. Use it to hold events back while a downstream
|
|
796
|
+
system is repaired, then continue exactly where you left off.
|
|
745
797
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
798
|
+
```typescript
|
|
799
|
+
dataPump.pause()
|
|
800
|
+
console.log(dataPump.isPaused) // true
|
|
801
|
+
|
|
802
|
+
// ... repair the downstream system ...
|
|
803
|
+
|
|
804
|
+
dataPump.resume() // delivery continues from the same position
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
While paused:
|
|
808
|
+
|
|
809
|
+
- The fetch loop keeps running and fills the buffer to `bufferSize`, then applies normal backpressure.
|
|
810
|
+
- The buffer and the cursor are retained. Nothing is lost and nothing is skipped.
|
|
811
|
+
- The pulse emitter keeps reporting, with `paused: true`, so the control plane still sees a live pump.
|
|
812
|
+
- A batch already inside the handler finishes and acknowledges, so the checkpoint stays accurate.
|
|
813
|
+
|
|
814
|
+
Both calls are idempotent. The pause flag is sticky across `restart()` and `stop()`/`start()`, so a repositioned or
|
|
815
|
+
bounced pump stays paused until `resume()` is called.
|
|
816
|
+
|
|
817
|
+
To restore a pause that you store somewhere durable, pass it at construction rather than calling `pause()` after
|
|
818
|
+
`start()`. The pump would otherwise deliver events in the gap between the two calls:
|
|
819
|
+
|
|
820
|
+
```typescript
|
|
821
|
+
const dataPump = FlowcoreDataPump.create({
|
|
822
|
+
// ...
|
|
823
|
+
paused: await myStore.isPaused(), // born paused, nothing escapes on the way up
|
|
824
|
+
})
|
|
750
825
|
```
|
|
751
826
|
|
|
827
|
+
Two limits to know:
|
|
828
|
+
|
|
829
|
+
- A paused pump holds up to `bufferSize` events in memory.
|
|
830
|
+
- `pause()` only governs the processor. It is ignored on a pump created without one, and it logs a warning, because
|
|
831
|
+
`reserve()` in pull mode would still hand out events and the `paused` pulse flag would be a false claim.
|
|
832
|
+
|
|
833
|
+
In cluster mode, call `pause()` and `resume()` on the `FlowcoreDataPumpCluster`, not on the pump. The flag is held on
|
|
834
|
+
the cluster and re-applied to the pump each new leader builds, so a failover does not silently resume delivery. The
|
|
835
|
+
flag is in memory only — persist it in your coordinator or control plane and pass it back through the `paused` option
|
|
836
|
+
if it must survive a rolling deploy.
|
|
837
|
+
|
|
838
|
+
Restart clears the current buffer and refreshes available time buckets. In 0.22.x, the optional
|
|
839
|
+
`restart(state, stopAt)` argument updates the option but does not rebuild the internal stop boundary. Create a new pump
|
|
840
|
+
when changing `stopAt`.
|
|
841
|
+
|
|
752
842
|
#### Pull Mode Methods (Manual Processing)
|
|
753
843
|
|
|
754
844
|
```typescript
|
|
@@ -756,14 +846,40 @@ const events = await dataPump.reserve(10) // Mark 10 events as reserved for proc
|
|
|
756
846
|
|
|
757
847
|
await dataPump.acknowledge(events.map((e) => e.eventId))
|
|
758
848
|
|
|
849
|
+
// Terminal: removes these events. This does not retry them.
|
|
759
850
|
await dataPump.fail(["event-id-1", "event-id-2"])
|
|
760
851
|
|
|
761
|
-
//
|
|
852
|
+
// Called only after timeout-based redelivery exceeds maxRedeliveryCount.
|
|
762
853
|
dataPump.onFinalyFailed(async (failedEvents) => {
|
|
763
854
|
console.log(`${failedEvents.length} events permanently failed`)
|
|
764
855
|
})
|
|
765
856
|
```
|
|
766
857
|
|
|
858
|
+
`onFinalyFailed` is the current public spelling.
|
|
859
|
+
|
|
860
|
+
## Pulse Status Reporting
|
|
861
|
+
|
|
862
|
+
The optional pulse emitter reports pump status to a Flowcore control-plane endpoint:
|
|
863
|
+
|
|
864
|
+
```typescript
|
|
865
|
+
const dataPump = FlowcoreDataPump.create({
|
|
866
|
+
// ...required options...
|
|
867
|
+
pulse: {
|
|
868
|
+
url: process.env.FLOWCORE_CONTROL_PLANE_URL!,
|
|
869
|
+
pathwayId: process.env.FLOWCORE_PATHWAY_ID!,
|
|
870
|
+
sourceId: process.env.PUMP_SOURCE_ID,
|
|
871
|
+
intervalMs: 30_000,
|
|
872
|
+
successLogLevel: "debug",
|
|
873
|
+
failureLogLevel: "warn",
|
|
874
|
+
},
|
|
875
|
+
})
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
Pulses include the current bucket and event ID, live status, buffer depth and reserved count, payload bytes, cumulative
|
|
879
|
+
pulled, acknowledged and failed counts, and uptime. The first pulse is randomly staggered within the interval so replicas
|
|
880
|
+
do not all report at once. Pulse failures are logged and do not stop processing. `pulse.url` is independent of
|
|
881
|
+
`baseUrlOverride`.
|
|
882
|
+
|
|
767
883
|
## Monitoring & Metrics
|
|
768
884
|
|
|
769
885
|
The data pump exposes Prometheus-compatible metrics:
|
|
@@ -772,9 +888,9 @@ The data pump exposes Prometheus-compatible metrics:
|
|
|
772
888
|
import { dataPumpPromRegistry } from "@flowcore/data-pump"
|
|
773
889
|
|
|
774
890
|
// Express.js example
|
|
775
|
-
app.get("/metrics", (req, res) => {
|
|
891
|
+
app.get("/metrics", async (req, res) => {
|
|
776
892
|
res.set("Content-Type", dataPumpPromRegistry.contentType)
|
|
777
|
-
res.end(dataPumpPromRegistry.metrics())
|
|
893
|
+
res.end(await dataPumpPromRegistry.metrics())
|
|
778
894
|
})
|
|
779
895
|
```
|
|
780
896
|
|
|
@@ -787,5 +903,11 @@ app.get("/metrics", (req, res) => {
|
|
|
787
903
|
- `flowcore_data_pump_events_failed_counter` - Failed events
|
|
788
904
|
- `flowcore_data_pump_events_pulled_size_bytes_counter` - Data throughput
|
|
789
905
|
- `flowcore_data_pump_sdk_commands_counter` - API calls to Flowcore
|
|
790
|
-
|
|
791
|
-
|
|
906
|
+
- `flowcore_data_pump_cluster_active_workers_gauge` - Connected cluster workers
|
|
907
|
+
- `flowcore_data_pump_cluster_leader_status_gauge` - `1` on the elected leader
|
|
908
|
+
- `flowcore_data_pump_cluster_events_distributed_counter` - Events sent to workers
|
|
909
|
+
- `flowcore_data_pump_cluster_worker_acks_counter` - Successful worker batch acknowledgments
|
|
910
|
+
- `flowcore_data_pump_cluster_worker_fails_counter` - Failed worker batch deliveries
|
|
911
|
+
|
|
912
|
+
Pump event metrics use `tenant`, `data_core`, `flow_type`, and `event_type` labels. The SDK command counter uses only
|
|
913
|
+
`command`. Cluster metrics currently have no tenant or event labels and are process-local.
|