@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/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
- // make sure that api key has sufficient IAM permissions to access streaming operations (COLLABORATOR is an example of a role that has sufficient permissions)
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: "your-api-key",
18
- apiKeyId: "your-api-key-id",
16
+ apiKey: process.env.FLOWCORE_API_KEY!,
19
17
  },
20
18
  dataSource: {
21
- tenant: "your-tenant-name", // this should always be the tenant name, not the tenant id
22
- dataCore: "your-data-core", // if noTranslation is false, this should be the data core name, not the id
23
- flowType: "your-flow-type", // if noTranslation is false, this should be the flow type name, not the id
24
- eventTypes: ["event-type-1", "event-type-2", "event-type-3"], // if noTranslation is false, this should be the event type names, not the ids
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: () => null, // Start in live mode
29
- setState: (state) => console.log("Position:", state),
25
+ getState: () => loadState(),
26
+ setState: (state) => saveState(state),
30
27
  },
31
28
  processor: {
32
- // use this for automatic event lifecycle management
29
+ concurrency: 10, // Batch size passed to one handler invocation
33
30
  handler: async (events) => {
34
- console.log(`Processing ${events.length} events`)
35
- // Your event processing logic here
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
- await dataPump.start()
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 **tracks its exact position** using time buckets + event IDs:
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", // Currently processing 2 PM hour
92
- eventId: "abc-123-def-456" // Last successfully processed event
90
+ timeBucket: "20240315140000",
91
+ eventId: "abc-123-def-456"
93
92
  }
94
93
  ```
95
94
 
96
- **Critical capabilities:**
97
-
98
- - **Crash recovery**: Restart exactly where you left off (no duplicate processing)
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**: Process new events as they arrive (real-time)
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
- ### **⚡ Concurrency & Parallel Processing**
155
+ ### **Batch Size and Application Concurrency**
157
156
 
158
- Control how many events process simultaneously:
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, // Process up to 5 events in parallel
162
+ concurrency: 5,
163
163
  handler: async (events) => {
164
- // This batch could contain 1-5 events
165
- // All processed in parallel for efficiency
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
- **Performance considerations:**
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
- - **Higher concurrency**: Faster processing, more resource usage
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
- ### **🔧 Failure Handling & Retries**
177
-
178
- Automatic resilience for production systems:
176
+ Handler retries are driven by the acknowledgment timeout:
179
177
 
180
178
  ```
181
- Event fails Retry 1Retry 2Retry 3 Permanent failure
182
- ↓ ↓ ↓ ↓ ↓
183
- Log error Log retry Log retry Log retry failedHandler()
179
+ ReserveHandler throwsReservation times out ReopenReserve again
184
180
  ```
185
181
 
186
- **Configurable behavior:**
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
- - `maxRedeliveryCount`: How many retries before giving up
189
- - `failedHandler`: Your code to handle permanently failed events
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 processEvent(event)
214
+ await processIdempotently(event)
219
215
  }
220
- // Pump automatically acknowledges if successful
221
- // Pump automatically retries if errors thrown
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() // Just start and it runs automatically!
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 the entire event lifecycle manually.
235
+ **For advanced scenarios** - You control reservation and acknowledgment manually.
238
236
 
239
- - **You handle**: Reserve → Process → Acknowledge/Fail Custom retry logic
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
- try {
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
- // 3️⃣ YOU decide what succeeded vs failed
274
- const successfulIds = []
275
- const failedIds = []
276
-
277
- results.forEach((result, index) => {
278
- const eventId = events[index].eventId
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: "your-api-key",
320
- apiKeyId: "your-api-key-id"
296
+ apiKey: process.env.FLOWCORE_API_KEY!
321
297
  }
322
298
  ```
323
299
 
324
- > **💡 Important:** Make sure your API key has sufficient IAM permissions. The key should have **COLLABORATOR** role or
325
- > other IAM permissions that have access to streaming operations.
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 tracks your processing position so you can resume exactly where you left off after restarts, crashes,
346
- or deployments. It prevents duplicate processing and ensures no events are lost.
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 **live mode** (process new events only)
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
- stateManager: {
379
- getState: () => ({
380
- timeBucket: "20240101120000", // Hour bucket: 2024-01-01 12:00
381
- eventId: eventId, // Start from first event AFTER 12:30:00
382
- }),
383
- setState: (state) => {
384
- // Extract timestamp from event ID
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 'fs';
409
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
438
410
 
439
411
  stateManager: {
440
412
  getState: () => {
441
- try {
442
- const data = readFileSync('pump-state.json', 'utf8');
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
- try {
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, -- Instance identifier
441
+ id VARCHAR(50) PRIMARY KEY, -- Logical consumer identifier
483
442
  time_bucket VARCHAR(14) NOT NULL, -- "yyyyMMddHH0000"
484
- event_id VARCHAR(255), -- Last processed event ID
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
- // Critical decision: start fresh or fail fast?
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 multiple instances
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
- ### State Management Patterns
508
+ ### Independent Consumers and Shared Clusters
552
509
 
553
- #### Multi-Instance Coordination
510
+ Use separate state keys when instances intentionally process different event selections as independent consumers:
554
511
 
555
512
  ```typescript
556
- // Each instance processes different event types
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
- [instanceId] // Each instance has unique state
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
- [instanceId, state.timeBucket, state.eventId]
526
+ [consumerId, state.timeBucket, state.eventId]
571
527
  );
572
528
  }
573
529
  }
574
530
  ```
575
531
 
576
- #### Checkpoint Strategy
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
- ```typescript
579
- // Save state every N events for performance
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 | Reason |
599
- | ------------------------------- | ------------------------ | ---------------------------------- |
600
- | **Local development** | Memory | Fast iteration, no setup |
601
- | **Testing/CI** | Memory | Clean state per test run |
602
- | **Single instance, simple** | File-based | Persistence without DB complexity |
603
- | **Production, single instance** | Database | Reliability and backup integration |
604
- | **Multi-instance** | Database | Shared state coordination |
605
- | **High-throughput** | Database + Checkpointing | Performance optimization |
606
- | **Mission-critical** | Database + Monitoring | Full observability stack |
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: 5000 // Poll every 5 seconds
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` | Max retry attempts before marking event as failed |
651
- | `achknowledgeTimeoutMs` | `number` | `5000` | Timeout for event acknowledgment |
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` | Skip name-to-ID translation. This is mostly for performance reasons. |
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
- // Start the pump
775
+ // Blocking: resolves when the fetch loop stops and rejects on a fetch error.
728
776
  await dataPump.start()
729
777
 
730
- // Stop the pump
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 - stops current processing and resumes from new location
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", // Required: target time bucket
737
- eventId: "specific-event-id", // Optional: specific event (omit to start from first event in bucket)
788
+ timeBucket: "20240101120000",
789
+ eventId: "specific-event-id",
738
790
  })
791
+ ```
739
792
 
740
- // Restart with a new stop date - change both position AND stop condition
741
- dataPump.restart(
742
- { timeBucket: "20240101120000" },
743
- new Date("2024-01-02"), // New stopAt date (or null to remove limit)
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
- // Common restart patterns:
747
- // 1. Jump to historical data: dataPump.restart({ timeBucket: firstTimeBucket })
748
- // 2. Reprocess from error point: dataPump.restart(lastKnownGoodState)
749
- // 3. Start backfill operation: dataPump.restart({ timeBucket: "20240101000000" }, endDate)
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
- // Handle events that permanently failed (exceeded retry limit)
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
- All metrics include labels: `tenant`, `data_core`, `flow_type`, `event_type`
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.