@siglo/kafka 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +298 -2
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +83 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2017 Jerico de guzman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,299 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @siglo/kafka Adapter Guide
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@siglo/kafka` provides an enterprise event streaming consumer adapter for executing `Siglo` workflows from Apache Kafka topics. It handles binary and JSON payload deserialization, partition offset tracking, distributed idempotency deduplication, header-based tenant namespace routing, and graceful error recovery.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Overview & Architecture
|
|
8
|
+
|
|
9
|
+
In an event-driven architecture, Kafka delivers partitioned event streams to consumer groups. The `@siglo/kafka` adapter handles incoming messages, ensures exactly-once semantics via an `IdempotencyStore`, and executes the workflow within configured timeout bounds:
|
|
10
|
+
|
|
11
|
+
```mermaid
|
|
12
|
+
flowchart TD
|
|
13
|
+
Broker["Kafka Broker / Topic"] -->|Record: key, value, headers, offset| Consumer["KafkaJS Consumer"]
|
|
14
|
+
Consumer -->|eachMessage| Handler["createKafkaMessageHandler<br/>• Parse JSON / Buffer<br/>• Generate Idemp Key: kafka:topic:key|off<br/>• Claim Idempotency Key<br/>• Extract x-namespace"]
|
|
15
|
+
Handler --> WF["Siglo Workflow<br/>• Sagas & Compensations<br/>• Timeout Deadlines"]
|
|
16
|
+
WF -->|On Success| Success["Complete Claim (idempotencyStore)<br/>Commit Offset"]
|
|
17
|
+
WF -->|On Failure| Failure["Fail Claim (idempotencyStore)<br/>Re-throw / DLQ"]
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 2. Execution Model: In-Process vs. Distributed Step Execution
|
|
23
|
+
|
|
24
|
+
A common question in distributed event-driven systems is:
|
|
25
|
+
|
|
26
|
+
> **"If I have a 10-step workflow, will those 10 steps run across different consumer servers?"**
|
|
27
|
+
|
|
28
|
+
### Direct Answer: **No. By default, all 10 steps execute in-process on the single consumer server that pulled the Kafka message.**
|
|
29
|
+
|
|
30
|
+
`Siglo` is an **in-process orchestrator** rather than a multi-machine RPC scheduler (such as Temporal or AWS Step Functions).
|
|
31
|
+
|
|
32
|
+
When an instance of your consumer service pulls a Kafka record, the entire 10-step pipeline runs in-memory on that node:
|
|
33
|
+
|
|
34
|
+
```mermaid
|
|
35
|
+
flowchart TD
|
|
36
|
+
Broker["Kafka Broker"] -->|Topic Partition 0| Server["Consumer Server 1"]
|
|
37
|
+
Server -->|createKafkaMessageHandler| S1["Step 1 (Validate)"]
|
|
38
|
+
S1 -->|Direct in-memory promise| S2["Step 2 (Enrich)"]
|
|
39
|
+
S2 --> Dots["..."]
|
|
40
|
+
Dots --> S10["Step 10 (Audit)"]
|
|
41
|
+
S10 --> Commit["Commit Kafka Offset"]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Why In-Process Execution is Vastly Superior for 90%+ of Workloads
|
|
45
|
+
|
|
46
|
+
| Dimension | In-Process Orchestration (`Siglo`) | Distributed Step Orchestration (Temporal / Step Functions) |
|
|
47
|
+
| :--- | :--- | :--- |
|
|
48
|
+
| **Step-to-Step Latency** | **Microseconds** ($\approx 1\text{–}5\ \mu\text{s}$) — Direct async promise resolution. | **Milliseconds** ($\approx 15\text{–}100\ \text{ms}$) — Every step requires network RPC, queue write, and DB commit. |
|
|
49
|
+
| **End-to-End Latency (10 Steps)** | $\approx \mathbf{5\text{–}20\ \text{ms}}$ (pure business logic time) | $\approx \mathbf{200\text{–}1000\ \text{ms}}$ (dominated by orchestration network latency) |
|
|
50
|
+
| **Throughput per Node** | **10,000+ msgs/sec** (bounded only by CPU and I/O) | Typically 500–2,000 ops/sec (queue/database bottlenecks) |
|
|
51
|
+
| **Payload Limitations** | **None** — Pass TypeScript domain models, Buffers, Maps, or Streams in memory. | **Strict Limits** — Payloads must serialize to JSON (e.g., SQS 256 KB, Temporal 2 MB limits). |
|
|
52
|
+
| **Infrastructure Overhead** | **Zero** — Runs directly inside your application container. | **Heavy** — Requires external clusters (History, Matching, Frontend engines, Cassandra/Postgres). |
|
|
53
|
+
| **Debugging & Local Testing** | **Trivial** — Set standard debugger breakpoints stepping from Step 1 to Step 2. | **Difficult** — Requires cross-machine distributed trace correlation and event replay. |
|
|
54
|
+
| **Saga Rollback Latency** | **Immediate** — Atomic reverse rollback in local memory if Step 8 fails. | **Delayed** — Rollback messages queue behind other cluster tasks. |
|
|
55
|
+
|
|
56
|
+
#### 1. The "10 Network Hops" Tax
|
|
57
|
+
In systems that distribute every individual step to different servers:
|
|
58
|
+
$$\text{Total Overhead} = 10 \times (\text{JSON Serialization} + \text{Queue Network Write} + \text{Broker Scheduling} + \text{Queue Network Read} + \text{Deserialization})$$
|
|
59
|
+
In such systems, **95% of total workflow duration is spent waiting for network hops and queue contention**, and only 5% executing real code. In `Siglo`, Step 1 immediately yields to Step 2 in memory with zero serialization penalty.
|
|
60
|
+
|
|
61
|
+
#### 2. How High-Throughput Scaling Actually Works
|
|
62
|
+
Work is scaled horizontally by **Kafka Partitions (Message-Level Distribution)**, not by splitting individual steps across machines:
|
|
63
|
+
|
|
64
|
+
```mermaid
|
|
65
|
+
flowchart TD
|
|
66
|
+
Topic["Kafka: orders Topic (4 Partitions)"]
|
|
67
|
+
Topic --> P0["Partition 0"]
|
|
68
|
+
Topic --> P1["Partition 1"]
|
|
69
|
+
Topic --> P2["Partition 2"]
|
|
70
|
+
Topic --> P3["Partition 3"]
|
|
71
|
+
|
|
72
|
+
P0 --> S0["Server 1<br/>(Runs steps 1-10 for Msg A)"]
|
|
73
|
+
P1 --> S1["Server 2<br/>(Runs steps 1-10 for Msg B)"]
|
|
74
|
+
P2 --> S2["Server 3<br/>(Runs steps 1-10 for Msg C)"]
|
|
75
|
+
P3 --> S3["Server 4<br/>(Runs steps 1-10 for Msg D)"]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Each consumer server processes independent messages in parallel at full hardware capability with maximum CPU cache locality.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 3. How to Distribute Steps Across Different Servers (When Needed)
|
|
83
|
+
|
|
84
|
+
Distributing individual steps across physically separate machines is only necessary in specific architectural scenarios:
|
|
85
|
+
1. **Heterogeneous Compute**: Steps 1–3 run on standard Node.js CPU instances, but Step 4 requires dedicated GPU worker nodes (e.g. AI model inference).
|
|
86
|
+
2. **Distinct Microservice Boundaries**: Steps 1–3 belong to the *Checkout Team*, while Steps 4–7 belong to the *Fulfillment Team*.
|
|
87
|
+
3. **Multi-Hour Uninterruptible Tasks**: Tasks taking hours where a crash cannot afford restarting from Step 1.
|
|
88
|
+
|
|
89
|
+
For these cases, implement **Event-Driven Choreography** using intermediate Kafka topics:
|
|
90
|
+
|
|
91
|
+
```mermaid
|
|
92
|
+
flowchart LR
|
|
93
|
+
subgraph Stage1["STAGE 1: Order Service (Server A)"]
|
|
94
|
+
T1["Topic 1: orders.created"] --> SF1["Subflow 1<br/>(Steps 1 - 3)"]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
subgraph Stage2["STAGE 2: AI GPU Worker (Server B)"]
|
|
98
|
+
T2["Topic 2: orders.validated"] --> SF2["Subflow 2<br/>(Steps 4 - 7)"]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
subgraph Stage3["STAGE 3: Ledger Service (Server C)"]
|
|
102
|
+
T3["Topic 3: images.rendered"] --> SF3["Subflow 3<br/>(Steps 8 - 10)"]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
SF1 -->|Produce to orders.validated| T2
|
|
106
|
+
SF2 -->|Produce to images.rendered| T3
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Pattern Example: Chaining Workflows Across Topics
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// --- SERVICE A: Order Service (Runs Steps 1 to 3) ---
|
|
113
|
+
const step1 = Step.create('validate', async (order: any) => ({ valid: true, order }));
|
|
114
|
+
const step2 = Step.create('checkCredit', async (ctx: any) => ({ creditApproved: true }));
|
|
115
|
+
const step3_publish = Step.create('publishValidated', async (ctx: any) => {
|
|
116
|
+
// Produce message to intermediate topic for Service B
|
|
117
|
+
await kafkaProducer.send({
|
|
118
|
+
topic: 'orders.validated',
|
|
119
|
+
messages: [{ key: ctx.order.id, value: JSON.stringify(ctx) }],
|
|
120
|
+
});
|
|
121
|
+
return { published: true };
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const stage1Flow = flow(step1, step2, step3_publish);
|
|
125
|
+
const stage1Handler = createKafkaMessageHandler(stage1Flow);
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
// --- SERVICE B: GPU Processing Service (Runs Steps 4 to 6) ---
|
|
129
|
+
const step4 = Step.create('generateAssets', async (ctx: any) => ({ assetUrl: 'https://...' }));
|
|
130
|
+
const step5 = Step.create('renderVideo', async (ctx: any) => ({ videoRendered: true }));
|
|
131
|
+
const step6_publish = Step.create('publishRendered', async (ctx: any) => {
|
|
132
|
+
await kafkaProducer.send({
|
|
133
|
+
topic: 'images.rendered',
|
|
134
|
+
messages: [{ key: ctx.order.id, value: JSON.stringify(ctx) }],
|
|
135
|
+
});
|
|
136
|
+
return { dispatched: true };
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const stage2Flow = flow(step4, step5, step6_publish);
|
|
140
|
+
const stage2Handler = createKafkaMessageHandler(stage2Flow);
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 4. Crash Recovery, Rebalances & Failure Semantics
|
|
146
|
+
|
|
147
|
+
What happens if Server 1 is executing **Step 6** of 10 and the process crashes?
|
|
148
|
+
|
|
149
|
+
1. **Uncommitted Offset**: The `@siglo/kafka` adapter only returns and acknowledges the Kafka record after the workflow completes. Because Step 6 failed or the process died, the offset is **never committed**.
|
|
150
|
+
2. **Kafka Partition Rebalance**: Kafka detects consumer heartbeat loss and reassigns that topic partition to another consumer (Server 2).
|
|
151
|
+
3. **Idempotency Protection**: Server 2 re-receives the message from the last committed offset. When it calls `createKafkaMessageHandler`, the configured `IdempotencyStore` (`@siglo/redis` or `@siglo/postgres`) ensures external payments or mutations aren't duplicated.
|
|
152
|
+
4. **Durable Resumption**: If durable execution was used with `@siglo/postgres`, state saved from prior checkpoints can be loaded via `restoreCheckpoint()`.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 5. Installation & Requirements
|
|
157
|
+
|
|
158
|
+
Install `@siglo/kafka` and `kafkajs`:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
npm install @siglo/core @siglo/kafka kafkajs
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Peer dependency matrix:
|
|
165
|
+
- `Siglo`: `*` (v1.0.0+)
|
|
166
|
+
- `kafkajs`: `^2.0.0`
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 6. API Reference
|
|
171
|
+
|
|
172
|
+
### `createKafkaMessageHandler(workflow, options)`
|
|
173
|
+
|
|
174
|
+
Creates an asynchronous message consumer handler:
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
function createKafkaMessageHandler<TInput = any, TOutput = any>(
|
|
178
|
+
workflow: Flow<TInput, TOutput>,
|
|
179
|
+
options?: SigloKafkaOptions<TInput, TOutput>
|
|
180
|
+
): (payload: { topic: string; partition: number; message: SigloKafkaMessage }) => Promise<any>;
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### `SigloKafkaMessage` Interface
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
export interface SigloKafkaMessage {
|
|
187
|
+
key?: Buffer | string | null;
|
|
188
|
+
value?: Buffer | string | null;
|
|
189
|
+
partition: number;
|
|
190
|
+
topic: string;
|
|
191
|
+
offset: string;
|
|
192
|
+
headers?: Record<string, Buffer | string | undefined>;
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## 7. Configuration Options (`SigloKafkaOptions`)
|
|
199
|
+
|
|
200
|
+
| Option | Type | Default | Description |
|
|
201
|
+
| :--- | :--- | :--- | :--- |
|
|
202
|
+
| `input` | `(message: SigloKafkaMessage) => Promise<TInput> \| TInput` | JSON parser on `message.value` | Custom transformer converting Kafka record into workflow input. |
|
|
203
|
+
| `output` | `(result: any, message: SigloKafkaMessage) => Promise<TOutput> \| TOutput` | `result` | Custom post-processor formatting the workflow output. |
|
|
204
|
+
| `idempotencyStore` | `IdempotencyStore` | `undefined` | Distributed store (e.g., `RedisIdempotencyStore` or `PostgresIdempotencyStore`) to prevent duplicate processing. |
|
|
205
|
+
| `idempotencyTtlMs` | `number` | `86400000` (24h) | Time-to-live for idempotency claim lock. |
|
|
206
|
+
| `timeout` | `number` | `undefined` | SLA execution timeout in milliseconds. |
|
|
207
|
+
| `namespace` | `string \| ((message: SigloKafkaMessage) => string \| undefined)` | `message.headers['x-namespace']` | Namespace identifier for tenant partitioning. |
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## 8. Idempotency Key Semantics
|
|
212
|
+
|
|
213
|
+
Kafka guarantees at-least-once message delivery under network failures or consumer rebalances. `@siglo/kafka` constructs an idempotency key using the message record's natural identity:
|
|
214
|
+
|
|
215
|
+
$$\text{idempKey} = \text{kafka}:\langle\text{topic}\rangle:\langle\text{key} \lor (\text{partition}:\text{offset})\rangle$$
|
|
216
|
+
|
|
217
|
+
- If `message.key` is defined (e.g., `user_8819`), the key is `kafka:orders:user_8819`.
|
|
218
|
+
- If `message.key` is null, the key falls back to partition and offset: `kafka:orders:2:104921`.
|
|
219
|
+
|
|
220
|
+
### Claim States:
|
|
221
|
+
1. `'completed'`: The message was already processed in a previous attempt. The adapter immediately returns the cached result without executing the workflow.
|
|
222
|
+
2. `'in-progress'`: Throws `Error('Kafka message ... is already being processed concurrently')`, preventing duplicate parallel writes.
|
|
223
|
+
3. `'claimed'`: Executes workflow. On completion, saves result; on error, calls `store.fail()` so subsequent retries are permitted.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## 9. Complete Production Consumer Example
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
import { Kafka } from 'kafkajs';
|
|
231
|
+
import { Step, flow } from '@siglo/core';
|
|
232
|
+
import { createKafkaMessageHandler } from '@siglo/kafka';
|
|
233
|
+
import { PostgresIdempotencyStore } from '@siglo/postgres';
|
|
234
|
+
import { Pool } from 'pg';
|
|
235
|
+
|
|
236
|
+
// 1. Database & Idempotency Store
|
|
237
|
+
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
238
|
+
const idempotencyStore = new PostgresIdempotencyStore({ client: pgPool });
|
|
239
|
+
await idempotencyStore.ensureTable();
|
|
240
|
+
|
|
241
|
+
// 2. Define Transaction Processing Workflow
|
|
242
|
+
interface PaymentEvent {
|
|
243
|
+
paymentId: string;
|
|
244
|
+
accountId: string;
|
|
245
|
+
amount: number;
|
|
246
|
+
currency: string;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const validateAccount = Step.create('validateAccount', async (param: PaymentEvent) => {
|
|
250
|
+
console.log(`Checking account ${param.accountId} balance for payment ${param.paymentId}`);
|
|
251
|
+
return { accountValid: true };
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const debitAccount = Step.create('debitAccount', async (param: any) => {
|
|
255
|
+
console.log(`Debiting ${param.amount} ${param.currency} from ${param.accountId}`);
|
|
256
|
+
return { ledgerEntryId: `led_${Date.now()}`, debited: true };
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const paymentPipeline = flow(validateAccount, debitAccount);
|
|
260
|
+
|
|
261
|
+
// 3. Setup Kafka Consumer
|
|
262
|
+
const kafka = new Kafka({
|
|
263
|
+
clientId: 'payment-consumer-service',
|
|
264
|
+
brokers: ['localhost:9092'],
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const consumer = kafka.consumer({ groupId: 'payment-engine-group' });
|
|
268
|
+
|
|
269
|
+
const messageHandler = createKafkaMessageHandler(paymentPipeline, {
|
|
270
|
+
idempotencyStore,
|
|
271
|
+
idempotencyTtlMs: 3600000, // 1 hour claim lock
|
|
272
|
+
timeout: 5000, // 5s execution SLA
|
|
273
|
+
namespace: (msg) => String(msg.headers?.['x-tenant-id'] || 'default'),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await consumer.connect();
|
|
277
|
+
await consumer.subscribe({ topic: 'payments.inbound', fromBeginning: false });
|
|
278
|
+
|
|
279
|
+
await consumer.run({
|
|
280
|
+
eachMessage: async (payload) => {
|
|
281
|
+
try {
|
|
282
|
+
const result = await messageHandler(payload);
|
|
283
|
+
console.log(`Processed payment message offset ${payload.message.offset}:`, result);
|
|
284
|
+
} catch (err: any) {
|
|
285
|
+
console.error(`Failed processing message at offset ${payload.message.offset}: ${err.message}`);
|
|
286
|
+
// Depending on architecture: push to Dead Letter Queue (DLQ) or let consumer retry
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// 4. Graceful Shutdown
|
|
293
|
+
process.on('SIGTERM', async () => {
|
|
294
|
+
console.log('Disconnecting Kafka consumer...');
|
|
295
|
+
await consumer.disconnect();
|
|
296
|
+
await pgPool.end();
|
|
297
|
+
process.exit(0);
|
|
298
|
+
});
|
|
299
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Flow, IdempotencyStore } from '@siglo/core';
|
|
2
|
+
export interface SigloKafkaMessage {
|
|
3
|
+
key?: Buffer | string | null;
|
|
4
|
+
value?: Buffer | string | null;
|
|
5
|
+
partition: number;
|
|
6
|
+
topic: string;
|
|
7
|
+
offset: string;
|
|
8
|
+
headers?: Record<string, Buffer | string | undefined>;
|
|
9
|
+
}
|
|
10
|
+
export interface SigloKafkaOptions<TInput = any, TOutput = any> {
|
|
11
|
+
input?: (message: SigloKafkaMessage) => Promise<TInput> | TInput;
|
|
12
|
+
output?: (result: any, message: SigloKafkaMessage) => Promise<TOutput> | TOutput;
|
|
13
|
+
idempotencyStore?: IdempotencyStore;
|
|
14
|
+
idempotencyTtlMs?: number;
|
|
15
|
+
timeout?: number;
|
|
16
|
+
namespace?: string | ((message: SigloKafkaMessage) => string | undefined);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Creates a Kafka message consumer handler for executing a rivulet workflow.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createKafkaMessageHandler<TInput = any, TOutput = any>(workflow: Flow<TInput, TOutput>, options?: SigloKafkaOptions<TInput, TOutput>): (payload: {
|
|
22
|
+
topic: string;
|
|
23
|
+
partition: number;
|
|
24
|
+
message: SigloKafkaMessage;
|
|
25
|
+
}) => Promise<any>;
|
|
26
|
+
export type RivuletKafkaMessage = SigloKafkaMessage;
|
|
27
|
+
export type RivuletKafkaOptions<TInput = any, TOutput = any> = SigloKafkaOptions<TInput, TOutput>;
|
|
28
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAoB,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEvE,MAAM,WAAW,iBAAiB;IAC9B,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IAC7B,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC,CAAC;CACzD;AAED,MAAM,WAAW,iBAAiB,CAAC,MAAM,GAAG,GAAG,EAAE,OAAO,GAAG,GAAG;IAC1D,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,iBAAiB,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;IACjE,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC;IACjF,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,OAAO,EAAE,iBAAiB,KAAK,MAAM,GAAG,SAAS,CAAC,CAAC;CAC7E;AAiBD;;GAEG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,GAAG,GAAG,EAAE,OAAO,GAAG,GAAG,EACjE,QAAQ,EAAE,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,OAAO,GAAE,iBAAiB,CAAC,MAAM,EAAE,OAAO,CAAM,aAEzB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,iBAAiB,CAAA;CAAE,KAAG,OAAO,CAAC,GAAG,CAAC,CA+DzG;AAGD,MAAM,MAAM,mBAAmB,GAAG,iBAAiB,CAAC;AACpD,MAAM,MAAM,mBAAmB,CAAC,MAAM,GAAG,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,iBAAiB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createKafkaMessageHandler = createKafkaMessageHandler;
|
|
4
|
+
function unwrapContext(obj) {
|
|
5
|
+
if (!obj || typeof obj !== 'object') {
|
|
6
|
+
return obj;
|
|
7
|
+
}
|
|
8
|
+
const clean = {};
|
|
9
|
+
for (const key of Object.keys(obj)) {
|
|
10
|
+
if (key.startsWith('$')) {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const val = typeof obj[key] === 'function' ? (obj[key].val !== undefined ? obj[key].val : (obj[key].length === 0 ? obj[key]() : obj[key])) : obj[key];
|
|
14
|
+
clean[key] = val;
|
|
15
|
+
}
|
|
16
|
+
return clean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Creates a Kafka message consumer handler for executing a rivulet workflow.
|
|
20
|
+
*/
|
|
21
|
+
function createKafkaMessageHandler(workflow, options = {}) {
|
|
22
|
+
return async (payload) => {
|
|
23
|
+
const { message, topic, partition } = payload;
|
|
24
|
+
const msgKey = message.key ? String(message.key) : `${topic}:${partition}:${message.offset}`;
|
|
25
|
+
const idempKey = `kafka:${topic}:${msgKey}`;
|
|
26
|
+
if (options.idempotencyStore) {
|
|
27
|
+
const status = await options.idempotencyStore.claim(idempKey, options.idempotencyTtlMs);
|
|
28
|
+
if (status === 'completed') {
|
|
29
|
+
return options.idempotencyStore.getResult?.(idempKey) ?? { deduplicated: true };
|
|
30
|
+
}
|
|
31
|
+
if (status === 'in-progress') {
|
|
32
|
+
throw new Error(`Kafka message ${idempKey} is already being processed concurrently`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
let parsedValue = {};
|
|
37
|
+
if (options.input) {
|
|
38
|
+
parsedValue = await options.input(message);
|
|
39
|
+
}
|
|
40
|
+
else if (message.value) {
|
|
41
|
+
const str = Buffer.isBuffer(message.value)
|
|
42
|
+
? message.value.toString('utf-8')
|
|
43
|
+
: (typeof message.value === 'string' ? message.value : String(message.value));
|
|
44
|
+
try {
|
|
45
|
+
parsedValue = JSON.parse(str);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
parsedValue = { raw: str };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
let namespace;
|
|
52
|
+
if (typeof options.namespace === 'function') {
|
|
53
|
+
namespace = options.namespace(message);
|
|
54
|
+
}
|
|
55
|
+
else if (options.namespace) {
|
|
56
|
+
namespace = options.namespace;
|
|
57
|
+
}
|
|
58
|
+
else if (message.headers?.['x-namespace']) {
|
|
59
|
+
namespace = String(message.headers['x-namespace']);
|
|
60
|
+
}
|
|
61
|
+
const execOptions = {
|
|
62
|
+
timeout: options.timeout,
|
|
63
|
+
namespace,
|
|
64
|
+
};
|
|
65
|
+
const rawResult = await workflow.run(parsedValue, execOptions);
|
|
66
|
+
const cleanResult = unwrapContext(rawResult);
|
|
67
|
+
if (options.idempotencyStore) {
|
|
68
|
+
await options.idempotencyStore.complete(idempKey, cleanResult);
|
|
69
|
+
}
|
|
70
|
+
if (options.output) {
|
|
71
|
+
return await options.output(cleanResult, message);
|
|
72
|
+
}
|
|
73
|
+
return cleanResult;
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (options.idempotencyStore) {
|
|
77
|
+
await options.idempotencyStore.fail(idempKey, err);
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@siglo/kafka",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Kafka consumer message handler and event streaming adapter for Siglo workflows",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"require": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public",
|
|
23
|
+
"registry": "https://registry.npmjs.org/"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.json",
|
|
27
|
+
"prepublishOnly": "npm run build",
|
|
28
|
+
"test": "npm run build && mocha -r tsx 'src/**/*.test.ts'"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/jsoftgem/siglo.git",
|
|
33
|
+
"directory": "packages/kafka"
|
|
34
|
+
},
|
|
35
|
+
"author": "Jerico de Guzman",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/jsoftgem/siglo/issues"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/jsoftgem/siglo#readme",
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@siglo/core": "^1.0.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"kafkajs": "^2.0.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"kafkajs": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/chai": "^5.2.3",
|
|
54
|
+
"@types/mocha": "^10.0.10",
|
|
55
|
+
"@types/node": "^26.6.5",
|
|
56
|
+
"chai": "^3.5.0",
|
|
57
|
+
"mocha": "^12.0.3",
|
|
58
|
+
"tsx": "^4.23.15",
|
|
59
|
+
"typescript": "~5.7.0"
|
|
60
|
+
}
|
|
61
|
+
}
|