@prosody-events/prosody 0.4.0 → 0.5.1
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 +346 -239
- package/bindings.d.ts +430 -174
- package/bindings.js +211 -83
- package/index.d.ts +231 -34
- package/index.js +699 -246
- package/package.json +21 -21
package/README.md
CHANGED
|
@@ -22,62 +22,86 @@ npm install @prosody-events/prosody
|
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
The package ships TypeScript declarations for the public API.
|
|
25
|
-
`EventHandler<P>` carries
|
|
26
|
-
|
|
27
|
-
Unparameterized
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
`EventHandler<P, R>` carries application payload and response types through each
|
|
26
|
+
handler method. Keyed-state definitions carry their item types through
|
|
27
|
+
`context.state()`. Unparameterized API types default to `JsonValue`. See the
|
|
28
|
+
[strict TypeScript examples](examples/) for IDE-ready projects compiled by the
|
|
29
|
+
repository typecheck.
|
|
30
30
|
|
|
31
31
|
## Quick Start
|
|
32
32
|
|
|
33
|
+
Run each example in an asynchronous function unless the example defines one.
|
|
34
|
+
|
|
33
35
|
```javascript
|
|
34
36
|
const { ProsodyClient } = require("@prosody-events/prosody");
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
async function main() {
|
|
39
|
+
// Initialize the client with Kafka bootstrap servers, consumer group, and topics
|
|
40
|
+
const client = await ProsodyClient.create({
|
|
41
|
+
// Bootstrap servers should normally be set using the PROSODY_BOOTSTRAP_SERVERS environment variable
|
|
42
|
+
bootstrapServers: "localhost:9092",
|
|
40
43
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
// To allow loopbacks, sourceSystem must be different from groupId.
|
|
45
|
+
// Normally, sourceSystem is omitted and defaults to groupId.
|
|
46
|
+
sourceSystem: "my-application-source",
|
|
44
47
|
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
// groupId should be set to the name of your application
|
|
49
|
+
groupId: "my-consumer-group",
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
});
|
|
51
|
+
// Topics the client should subscribe to
|
|
52
|
+
subscribedTopics: "my-topic",
|
|
53
|
+
});
|
|
51
54
|
|
|
52
|
-
// Define a message handler
|
|
53
|
-
const messageHandler = {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// Define a message handler
|
|
56
|
+
const messageHandler = {
|
|
57
|
+
onExcise: async (context, message, signal) => {
|
|
58
|
+
console.log(`Excise key: ${message.key}`);
|
|
59
|
+
await context.clearScheduled();
|
|
60
|
+
return null;
|
|
61
|
+
},
|
|
57
62
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
await context.schedule(followupTime);
|
|
62
|
-
}
|
|
63
|
-
},
|
|
63
|
+
onMessage: async (context, message, signal) => {
|
|
64
|
+
// Process the received message
|
|
65
|
+
console.log(`Received message: ${JSON.stringify(message)}`);
|
|
64
66
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
67
|
+
// Schedule a timer for delayed processing
|
|
68
|
+
if (message.payload.scheduleFollowup) {
|
|
69
|
+
const followupTime = new Date(Date.now() + 30000); // 30 seconds from now
|
|
70
|
+
await context.schedule(followupTime);
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
},
|
|
70
74
|
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
onTimer: async (context, timer, signal) => {
|
|
76
|
+
// Handle timer firing
|
|
77
|
+
console.log(`Timer fired for key: ${timer.key} at ${timer.time}`);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Subscribe to messages using the message handler
|
|
82
|
+
await client.subscribe(messageHandler);
|
|
73
83
|
|
|
74
|
-
// Send a message to a topic
|
|
75
|
-
await client.send("my-topic", "message-key", { content: "Hello, Kafka!" });
|
|
84
|
+
// Send a message to a topic
|
|
85
|
+
await client.send("my-topic", "message-key", { content: "Hello, Kafka!" });
|
|
86
|
+
await client.excise("my-topic", "obsolete-key");
|
|
87
|
+
|
|
88
|
+
// Shut down all client services when done
|
|
89
|
+
await client.shutdown();
|
|
90
|
+
}
|
|
76
91
|
|
|
77
|
-
|
|
78
|
-
await client.unsubscribe();
|
|
92
|
+
main().catch(console.error);
|
|
79
93
|
```
|
|
80
94
|
|
|
95
|
+
## Excise records
|
|
96
|
+
|
|
97
|
+
Applications can copy event data into keyed state and external stores. A regulatory or contractual deletion must remove every copy for one key.
|
|
98
|
+
|
|
99
|
+
An excise record carries this deletion command. Kafka encodes the command as a key with no payload. During topic compaction, Kafka deletes earlier values for the key. Call `excise(topic, key)` to send the record. Prosody routes the record to `onExcise`. The handler must delete all consumer-owned data for the key.
|
|
100
|
+
|
|
101
|
+
Each handler must implement `onMessage`, `onExcise`, and `onTimer`. Subscription fails before consumption if a method is missing.
|
|
102
|
+
|
|
103
|
+
If an excise record is a request, return a response from `onExcise`. Prosody uses this response as the subsystem result.
|
|
104
|
+
|
|
81
105
|
## Architecture
|
|
82
106
|
|
|
83
107
|
Prosody enables efficient, parallel processing of Kafka messages while maintaining order for messages with the same key:
|
|
@@ -116,7 +140,7 @@ When the timer fires, reload the message from Kafka and retry.
|
|
|
116
140
|
|
|
117
141
|
```javascript
|
|
118
142
|
// Configure defer behavior
|
|
119
|
-
const client =
|
|
143
|
+
const client = await ProsodyClient.create({
|
|
120
144
|
groupId: "my-consumer-group",
|
|
121
145
|
subscribedTopics: "my-topic",
|
|
122
146
|
deferEnabled: true, // Enable deferral (default: true)
|
|
@@ -138,7 +162,7 @@ with a transient error, routing them through defer.
|
|
|
138
162
|
|
|
139
163
|
```javascript
|
|
140
164
|
// Configure monopolization detection
|
|
141
|
-
const client =
|
|
165
|
+
const client = await ProsodyClient.create({
|
|
142
166
|
groupId: "my-consumer-group",
|
|
143
167
|
subscribedTopics: "my-topic",
|
|
144
168
|
monopolizationEnabled: true, // Enable detection (default: true)
|
|
@@ -152,7 +176,7 @@ const client = new ProsodyClient({
|
|
|
152
176
|
Handlers are automatically cancelled if they exceed a deadline:
|
|
153
177
|
|
|
154
178
|
```javascript
|
|
155
|
-
const client =
|
|
179
|
+
const client = await ProsodyClient.create({
|
|
156
180
|
groupId: "my-consumer-group",
|
|
157
181
|
subscribedTopics: "my-topic",
|
|
158
182
|
timeoutMs: 30000, // Cancel after 30 seconds
|
|
@@ -165,132 +189,11 @@ exit promptly. If not specified, timeout defaults to 80% of `stallThresholdMs`.
|
|
|
165
189
|
|
|
166
190
|
## Configuration
|
|
167
191
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
### Core
|
|
171
|
-
|
|
172
|
-
| Option / Environment Variable | Description | Default |
|
|
173
|
-
| ------------------------------------------------ | ------------------------------------------------- | ----------- |
|
|
174
|
-
| `bootstrapServers` / `PROSODY_BOOTSTRAP_SERVERS` | Kafka servers to connect to | - |
|
|
175
|
-
| `groupId` / `PROSODY_GROUP_ID` | Consumer group name | - |
|
|
176
|
-
| `subscribedTopics` / `PROSODY_SUBSCRIBED_TOPICS` | Topics to read from | - |
|
|
177
|
-
| `allowedEvents` / `PROSODY_ALLOWED_EVENTS` | Only process events matching these prefixes | (all) |
|
|
178
|
-
| `sourceSystem` / `PROSODY_SOURCE_SYSTEM` | Tag for outgoing messages (prevents reprocessing) | `<groupId>` |
|
|
179
|
-
| `mock` / `PROSODY_MOCK` | Use in-memory Kafka for testing | false |
|
|
180
|
-
|
|
181
|
-
### Consumer
|
|
182
|
-
|
|
183
|
-
| Option / Environment Variable | Description | Default |
|
|
184
|
-
| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
|
|
185
|
-
| `maxConcurrency` / `PROSODY_MAX_CONCURRENCY` | Max messages being processed simultaneously | 32 |
|
|
186
|
-
| `maxUncommitted` / `PROSODY_MAX_UNCOMMITTED` | Max queued messages before pausing consumption | 64 |
|
|
187
|
-
| `timeoutMs` / `PROSODY_TIMEOUT` | Cancel handler if it runs longer than this | 80% of stall threshold |
|
|
188
|
-
| `commitIntervalMs` / `PROSODY_COMMIT_INTERVAL` | How often to save progress to Kafka | 1s |
|
|
189
|
-
| `pollIntervalMs` / `PROSODY_POLL_INTERVAL` | How often to fetch new messages from Kafka | 100ms |
|
|
190
|
-
| `shutdownTimeoutMs` / `PROSODY_SHUTDOWN_TIMEOUT` | Wait this long for in-flight work before force-quit | 30s |
|
|
191
|
-
| `stallThresholdMs` / `PROSODY_STALL_THRESHOLD` | Report unhealthy if no progress for this long | 5m |
|
|
192
|
-
| `probePort` / `PROSODY_PROBE_PORT` | HTTP port for health checks (null to disable) | 8000 |
|
|
193
|
-
| `failureTopic` / `PROSODY_FAILURE_TOPIC` | Send unprocessable messages here (dead letter queue) | - |
|
|
194
|
-
| `idempotenceCacheSize` / `PROSODY_IDEMPOTENCE_CACHE_SIZE` | Global shared cache capacity across all partitions for deduplicating messages. Must be greater than 0 (a value of 0 is rejected). | 8192 |
|
|
195
|
-
| `idempotenceVersion` / `PROSODY_IDEMPOTENCE_VERSION` | Version string for cache-busting dedup hashes | `"1"` |
|
|
196
|
-
| `idempotenceTtlS` / `PROSODY_IDEMPOTENCE_TTL` | TTL for dedup records in Cassandra in seconds | 604800 |
|
|
197
|
-
| `slabSizeMs` / `PROSODY_SLAB_SIZE` | Timer storage granularity (rarely needs changing) | 1h |
|
|
198
|
-
| `messageSpans` / `PROSODY_MESSAGE_SPANS` | Span linking for message execution: `child` (child-of) or `follows_from` | `child` |
|
|
199
|
-
| `timerSpans` / `PROSODY_TIMER_SPANS` | Span linking for timer execution: `child` (child-of) or `follows_from` | `follows_from` |
|
|
200
|
-
|
|
201
|
-
### Producer
|
|
202
|
-
|
|
203
|
-
| Option / Environment Variable | Description | Default |
|
|
204
|
-
| ---------------------------------------- | ------------------------------- | ------- |
|
|
205
|
-
| `sendTimeoutMs` / `PROSODY_SEND_TIMEOUT` | Give up sending after this long | 1s |
|
|
206
|
-
|
|
207
|
-
### Retry
|
|
208
|
-
|
|
209
|
-
When a handler fails, retry with exponential backoff:
|
|
210
|
-
|
|
211
|
-
| Option / Environment Variable | Description | Default |
|
|
212
|
-
| --------------------------------------------- | --------------------------------- | ------- |
|
|
213
|
-
| `maxRetries` / `PROSODY_MAX_RETRIES` | Give up after this many attempts | 3 |
|
|
214
|
-
| `retryBaseMs` / `PROSODY_RETRY_BASE` | Wait this long before first retry | 20ms |
|
|
215
|
-
| `maxRetryDelayMs` / `PROSODY_RETRY_MAX_DELAY` | Never wait longer than this | 5m |
|
|
216
|
-
|
|
217
|
-
### Deferral (Pipeline Mode)
|
|
218
|
-
|
|
219
|
-
| Option / Environment Variable | Description | Default |
|
|
220
|
-
| ------------------------------------------------------------ | -------------------------------------------------------------- | ------- |
|
|
221
|
-
| `deferEnabled` / `PROSODY_DEFER_ENABLED` | Enable deferral for new messages | true |
|
|
222
|
-
| `deferBaseMs` / `PROSODY_DEFER_BASE` | Wait this long before first deferred retry | 1s |
|
|
223
|
-
| `deferMaxDelayMs` / `PROSODY_DEFER_MAX_DELAY` | Never wait longer than this | 24h |
|
|
224
|
-
| `deferFailureThreshold` / `PROSODY_DEFER_FAILURE_THRESHOLD` | Disable deferral when failure rate exceeds this | 0.9 |
|
|
225
|
-
| `deferFailureWindowMs` / `PROSODY_DEFER_FAILURE_WINDOW` | Measure failure rate over this time window | 5m |
|
|
226
|
-
| `deferCacheSize` / `PROSODY_LOADER_CACHE_SIZE` | Track this many deferred keys in memory | 1024 |
|
|
227
|
-
| `deferStoreCacheSize` / `PROSODY_DEFER_STORE_CACHE_SIZE` | Maximum deferred store cache entries per Cassandra defer store | 8192 |
|
|
228
|
-
| `deferSeekTimeoutMs` / `PROSODY_LOADER_SEEK_TIMEOUT` | Timeout when loading deferred messages | 30s |
|
|
229
|
-
| `deferDiscardThreshold` / `PROSODY_LOADER_DISCARD_THRESHOLD` | Read optimization (rarely needs changing) | 100 |
|
|
230
|
-
|
|
231
|
-
### Monopolization Detection (Pipeline Mode)
|
|
232
|
-
|
|
233
|
-
| Option / Environment Variable | Description | Default |
|
|
234
|
-
| --------------------------------------------------------------- | -------------------------------------- | ------- |
|
|
235
|
-
| `monopolizationEnabled` / `PROSODY_MONOPOLIZATION_ENABLED` | Enable hot key protection | true |
|
|
236
|
-
| `monopolizationThreshold` / `PROSODY_MONOPOLIZATION_THRESHOLD` | Max handler time as fraction of window | 0.9 |
|
|
237
|
-
| `monopolizationWindowMs` / `PROSODY_MONOPOLIZATION_WINDOW` | Measurement window | 5m |
|
|
238
|
-
| `monopolizationCacheSize` / `PROSODY_MONOPOLIZATION_CACHE_SIZE` | Max distinct keys to track | 8192 |
|
|
239
|
-
|
|
240
|
-
### Fair Scheduling (All Modes)
|
|
241
|
-
|
|
242
|
-
| Option / Environment Variable | Description | Default |
|
|
243
|
-
| ------------------------------------------------------------- | -------------------------------------------------------------- | ------- |
|
|
244
|
-
| `schedulerFailureWeight` / `PROSODY_SCHEDULER_FAILURE_WEIGHT` | Fraction of processing time reserved for retries | 0.3 |
|
|
245
|
-
| `schedulerMaxWaitMs` / `PROSODY_SCHEDULER_MAX_WAIT` | Messages waiting this long get maximum priority | 2m |
|
|
246
|
-
| `schedulerWaitWeight` / `PROSODY_SCHEDULER_WAIT_WEIGHT` | Priority boost for waiting messages (higher = more aggressive) | 200.0 |
|
|
247
|
-
| `schedulerCacheSize` / `PROSODY_SCHEDULER_CACHE_SIZE` | Max distinct keys to track | 8192 |
|
|
248
|
-
|
|
249
|
-
### Telemetry Emitter
|
|
250
|
-
|
|
251
|
-
Produces message and timer lifecycle events to a Kafka topic for observability:
|
|
252
|
-
|
|
253
|
-
| Option / Environment Variable | Description | Default |
|
|
254
|
-
| ------------------------------------------------ | --------------------------------- | ------------------------ |
|
|
255
|
-
| `telemetryEnabled` / `PROSODY_TELEMETRY_ENABLED` | Produce lifecycle events to Kafka | true |
|
|
256
|
-
| `telemetryTopic` / `PROSODY_TELEMETRY_TOPIC` | Kafka topic for telemetry events | prosody.telemetry-events |
|
|
257
|
-
|
|
258
|
-
### Cassandra
|
|
259
|
-
|
|
260
|
-
Persistent storage for timers and deferred retries (not needed if `mock: true`):
|
|
261
|
-
|
|
262
|
-
| Option / Environment Variable | Description | Default |
|
|
263
|
-
| ----------------------------------------------------------- | ---------------------------------- | ------- |
|
|
264
|
-
| `cassandraNodes` / `PROSODY_CASSANDRA_NODES` | Servers to connect to (host:port) | - |
|
|
265
|
-
| `cassandraKeyspace` / `PROSODY_CASSANDRA_KEYSPACE` | Keyspace name | prosody |
|
|
266
|
-
| `cassandraUser` / `PROSODY_CASSANDRA_USER` | Username | - |
|
|
267
|
-
| `cassandraPassword` / `PROSODY_CASSANDRA_PASSWORD` | Password | - |
|
|
268
|
-
| `cassandraDatacenter` / `PROSODY_CASSANDRA_DATACENTER` | Prefer this datacenter for queries | - |
|
|
269
|
-
| `cassandraRack` / `PROSODY_CASSANDRA_RACK` | Prefer this rack for queries | - |
|
|
270
|
-
| `cassandraRetentionSeconds` / `PROSODY_CASSANDRA_RETENTION` | Delete data older than this | 1y |
|
|
271
|
-
|
|
272
|
-
### Keyed State
|
|
192
|
+
For the complete configuration reference, see [CONFIGURATION.md](CONFIGURATION.md).
|
|
273
193
|
|
|
274
|
-
|
|
194
|
+
Constructor options take precedence. Unset options use environment variables, then library defaults.
|
|
275
195
|
|
|
276
|
-
|
|
277
|
-
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
|
|
278
|
-
| `stateCollections` / - | Keyed-state collections to register before subscribe (array of collection configs; duplicate names are rejected) | (none) |
|
|
279
|
-
| `stateCacheDir` / `PROSODY_STATE_CACHE_DIR` | Disk workspace for the local keyed-state cache; each live client needs its own directory (it is locked exclusively) | per-client temp dir |
|
|
280
|
-
| `stateCacheSizeBytes` / `PROSODY_STATE_CACHE_SIZE_BYTES` | Capacity of the in-memory keyed-state cache, in bytes; must be a positive safe integer. One cache is shared by all partition keyspaces | engine default |
|
|
281
|
-
| `stateRecoveryDelaySeconds` / `PROSODY_STATE_RECOVERY_DELAY` | Delay between staging a provisional cell and the recovery sweep; every collection TTL must strictly exceed this. The option is whole seconds (e.g. `30`); the env var is a duration string (e.g. `30s`), second-granularity, min `1s`. | 30s |
|
|
282
|
-
|
|
283
|
-
Each `stateCollections` entry (a `StateCollectionConfig`) has these fields. Prefer the definition constructors (`value` / `map` / `deque` and their `message*` variants, documented below): they serialize into `stateCollections` so you declare each collection once and reuse the same object with `context.state()`.
|
|
284
|
-
|
|
285
|
-
| Field | Description | Default |
|
|
286
|
-
| ----------------- | ----------------------------------------------------------------------------------- | ---------- |
|
|
287
|
-
| `name` | Collection name; non-empty and unique within the client | (required) |
|
|
288
|
-
| `kind` | `"value"`, `"map"`, or `"deque"` | (required) |
|
|
289
|
-
| `payload` | `"json"` (JSON values) or `"message"` (the full Kafka message the handler received) | (required) |
|
|
290
|
-
| `ttlSeconds` | Per-write TTL in whole seconds (at least 1; must exceed the recovery delay) | (none) |
|
|
291
|
-
| `readUncommitted` | Opt out of transactional staging (read-uncommitted) | false |
|
|
292
|
-
| `keysetLimit` | Map-only; ordered-scan bound in `0..=4096` (`0` disables ordered-scan tracking) | 128 |
|
|
293
|
-
| `capacity` | Deque-only; bounded backlog (a whole number `>= 1`), enforced lazily on push | (none) |
|
|
196
|
+
Client construction is asynchronous. Replace `new ProsodyClient(config)` with `await ProsodyClient.create(config)`.
|
|
294
197
|
|
|
295
198
|
## Liveness and Readiness Probes
|
|
296
199
|
|
|
@@ -306,7 +209,7 @@ server is tied to the consumer's lifecycle and offers two main endpoints:
|
|
|
306
209
|
Configure the probe server using either the client constructor:
|
|
307
210
|
|
|
308
211
|
```javascript
|
|
309
|
-
const client =
|
|
212
|
+
const client = await ProsodyClient.create({
|
|
310
213
|
groupId: "my-consumer-group",
|
|
311
214
|
subscribedTopics: "my-topic",
|
|
312
215
|
probePort: 8000, // Set to null to disable
|
|
@@ -343,6 +246,60 @@ if (client.isStalled) {
|
|
|
343
246
|
}
|
|
344
247
|
```
|
|
345
248
|
|
|
249
|
+
## Subsystems
|
|
250
|
+
|
|
251
|
+
A consumer group ID identifies a set of processes that share records and the keyed state that the group owns. A subsystem can include one or more services and consumer groups. If callers use these IDs, a refactor can require changes to each caller.
|
|
252
|
+
|
|
253
|
+
A subsystem gives requests and published state one stable public name. Callers use this name instead of consumer group IDs. You can change its services and consumer groups without changing callers. Prosody uses the first response to a subsystem request. For each published-state read, it uses one consumer group that publishes the collection.
|
|
254
|
+
|
|
255
|
+
## Requests
|
|
256
|
+
|
|
257
|
+
Kafka decouples producers from consumers, so a send does not return consumer results. This asynchronous model lets each service process records independently. Some operations must wait for consumer results before they continue. A request recovers synchrony for the caller while consumers continue asynchronous processing.
|
|
258
|
+
|
|
259
|
+
Send a request from a handler or other application code. The Prosody client does not need an active subscription. The result map uses canonical subsystem names as keys. Each value is a `Success` or `Failure` outcome. Use `requestExcise` to send an excise record and collect the same outcome type.
|
|
260
|
+
|
|
261
|
+
Do not rely on map order. The map contains one entry for each selected subsystem. A missing response becomes a timeout `Failure`; Prosody does not omit the subsystem. The request rejects for request-level failures, such as invalid input, a Kafka send failure, or shutdown. Do not await a request if the current consumer group must process it for the same key. That group cannot process it until the handler returns.
|
|
262
|
+
|
|
263
|
+
Message and excise handler return values become successful outcomes. Each return value must have a JSON representation.
|
|
264
|
+
|
|
265
|
+
Set `subsystem` to `inventory` on the client that subscribes this handler.
|
|
266
|
+
|
|
267
|
+
```javascript
|
|
268
|
+
await client.subscribe({
|
|
269
|
+
onMessage: async (_context, message) => ({ accepted: message.key }),
|
|
270
|
+
onExcise: async (_context, message) => ({ accepted: message.key }),
|
|
271
|
+
onTimer: async () => {},
|
|
272
|
+
});
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Send the request:
|
|
276
|
+
|
|
277
|
+
```javascript
|
|
278
|
+
const subsystems = ["inventory", "billing"];
|
|
279
|
+
const results = await client.request(
|
|
280
|
+
"orders",
|
|
281
|
+
"order-1",
|
|
282
|
+
{ type: "order.created" },
|
|
283
|
+
{ subsystems, timeoutMs: 2_000 },
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
for (const [subsystem, outcome] of results) {
|
|
287
|
+
if (outcome.ok) console.log(`${subsystem}:`, outcome.value);
|
|
288
|
+
else console.error(`${subsystem}: ${outcome.error.message}`);
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
The example can print these results:
|
|
293
|
+
|
|
294
|
+
```text
|
|
295
|
+
inventory: { accepted: 'order-1' }
|
|
296
|
+
billing: no response arrived before the deadline
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Each failure contains one typed response error.
|
|
300
|
+
|
|
301
|
+
Each response error has one message.
|
|
302
|
+
|
|
346
303
|
## Advanced Usage
|
|
347
304
|
|
|
348
305
|
### Pipeline Mode
|
|
@@ -367,7 +324,7 @@ Kafka → Deduplication → Retry → Defer → Monopolization → Shutdown →
|
|
|
367
324
|
| Telemetry | Emits handler lifecycle events |
|
|
368
325
|
|
|
369
326
|
```javascript
|
|
370
|
-
const client =
|
|
327
|
+
const client = await ProsodyClient.create({
|
|
371
328
|
mode: Mode.Pipeline, // Default mode
|
|
372
329
|
groupId: "my-consumer-group",
|
|
373
330
|
subscribedTopics: "my-topic",
|
|
@@ -383,12 +340,12 @@ Tries a few times, then routes failures to a dead letter topic.
|
|
|
383
340
|
- Use when you need to keep moving and can reprocess failures later
|
|
384
341
|
|
|
385
342
|
```javascript
|
|
386
|
-
const client =
|
|
343
|
+
const client = await ProsodyClient.create({
|
|
387
344
|
mode: Mode.LowLatency,
|
|
388
345
|
groupId: "my-consumer-group",
|
|
389
346
|
subscribedTopics: "my-topic",
|
|
390
347
|
failureTopic: "failed-messages", // Required for low-latency mode
|
|
391
|
-
maxRetries: 3, //
|
|
348
|
+
maxRetries: 3, // Retry up to 3 times after the initial attempt
|
|
392
349
|
});
|
|
393
350
|
```
|
|
394
351
|
|
|
@@ -401,7 +358,7 @@ Logs failures and moves on.
|
|
|
401
358
|
- Use for development or when message loss is acceptable
|
|
402
359
|
|
|
403
360
|
```javascript
|
|
404
|
-
const client =
|
|
361
|
+
const client = await ProsodyClient.create({
|
|
405
362
|
mode: Mode.BestEffort,
|
|
406
363
|
groupId: "my-consumer-group",
|
|
407
364
|
subscribedTopics: "my-topic",
|
|
@@ -415,7 +372,7 @@ of events:
|
|
|
415
372
|
|
|
416
373
|
```javascript
|
|
417
374
|
// Process only events with types starting with "user." or "account."
|
|
418
|
-
const client =
|
|
375
|
+
const client = await ProsodyClient.create({
|
|
419
376
|
groupId: "my-consumer-group",
|
|
420
377
|
subscribedTopics: "my-topic",
|
|
421
378
|
allowedEvents: ["user.", "account."],
|
|
@@ -451,7 +408,7 @@ Prosody prevents processing loops in distributed systems by tracking the source
|
|
|
451
408
|
|
|
452
409
|
```javascript
|
|
453
410
|
// Consumer and producer in one application
|
|
454
|
-
const client =
|
|
411
|
+
const client = await ProsodyClient.create({
|
|
455
412
|
groupId: "my-service",
|
|
456
413
|
sourceSystem: "my-service-producer", // Must differ from groupId to allow loopbacks; defaults to groupId
|
|
457
414
|
subscribedTopics: "my-topic",
|
|
@@ -508,7 +465,7 @@ await client.send("my-topic", "key2", {
|
|
|
508
465
|
To invalidate all previously recorded dedup entries (forcing reprocessing of messages), change the version:
|
|
509
466
|
|
|
510
467
|
```javascript
|
|
511
|
-
const client =
|
|
468
|
+
const client = await ProsodyClient.create({
|
|
512
469
|
groupId: "my-consumer-group",
|
|
513
470
|
subscribedTopics: "my-topic",
|
|
514
471
|
idempotenceVersion: "2", // Changing this invalidates all previously recorded entries
|
|
@@ -541,6 +498,7 @@ const messageHandler = {
|
|
|
541
498
|
// Check what's scheduled
|
|
542
499
|
const scheduled = await context.scheduled();
|
|
543
500
|
console.log(`Scheduled timers: ${scheduled.length}`);
|
|
501
|
+
return null;
|
|
544
502
|
},
|
|
545
503
|
|
|
546
504
|
onTimer: async (context, timer, signal) => {
|
|
@@ -548,6 +506,11 @@ const messageHandler = {
|
|
|
548
506
|
console.log(`Key: ${timer.key}`);
|
|
549
507
|
console.log(`Scheduled time: ${timer.time}`);
|
|
550
508
|
},
|
|
509
|
+
|
|
510
|
+
onExcise: async (context) => {
|
|
511
|
+
await context.clearScheduled();
|
|
512
|
+
return null;
|
|
513
|
+
},
|
|
551
514
|
};
|
|
552
515
|
```
|
|
553
516
|
|
|
@@ -581,7 +544,7 @@ PROSODY_CASSANDRA_NODES=localhost:9042 # Required for timer persistence
|
|
|
581
544
|
Or programmatically when creating the client:
|
|
582
545
|
|
|
583
546
|
```javascript
|
|
584
|
-
const client =
|
|
547
|
+
const client = await ProsodyClient.create({
|
|
585
548
|
bootstrapServers: "localhost:9092",
|
|
586
549
|
groupId: "my-application",
|
|
587
550
|
subscribedTopics: "my-topic",
|
|
@@ -593,7 +556,7 @@ For testing, you can use mock mode to avoid Cassandra dependency:
|
|
|
593
556
|
|
|
594
557
|
```javascript
|
|
595
558
|
// Mock mode for testing (timers work but aren't persisted)
|
|
596
|
-
const client =
|
|
559
|
+
const client = await ProsodyClient.create({
|
|
597
560
|
bootstrapServers: "localhost:9092",
|
|
598
561
|
groupId: "my-application",
|
|
599
562
|
subscribedTopics: "my-topic",
|
|
@@ -606,6 +569,8 @@ const client = new ProsodyClient({
|
|
|
606
569
|
Prosody classifies errors as transient (temporary, can be retried) or permanent (won't be resolved by retrying). By
|
|
607
570
|
default, all errors are considered transient.
|
|
608
571
|
|
|
572
|
+
The error classes and decorators apply to `onMessage`, `onExcise`, and `onTimer`.
|
|
573
|
+
|
|
609
574
|
#### Using Decorators
|
|
610
575
|
|
|
611
576
|
If you're using TypeScript or a JavaScript environment that supports decorators, you can use the `@permanent` decorator
|
|
@@ -620,10 +585,17 @@ class MyHandler {
|
|
|
620
585
|
// Your message handling logic here
|
|
621
586
|
// TypeError and AttributeError will be treated as permanent
|
|
622
587
|
// All other exceptions will be treated as transient (default behavior)
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async onExcise() {
|
|
592
|
+
return null;
|
|
623
593
|
}
|
|
594
|
+
|
|
595
|
+
async onTimer() {}
|
|
624
596
|
}
|
|
625
597
|
|
|
626
|
-
const client =
|
|
598
|
+
const client = await ProsodyClient.create(config);
|
|
627
599
|
client.subscribe(new MyHandler());
|
|
628
600
|
```
|
|
629
601
|
|
|
@@ -645,10 +617,13 @@ const messageHandler = {
|
|
|
645
617
|
// All other exceptions will be treated as transient (default behavior)
|
|
646
618
|
throw error;
|
|
647
619
|
}
|
|
620
|
+
return null;
|
|
648
621
|
},
|
|
622
|
+
onExcise: async () => null,
|
|
623
|
+
onTimer: async () => {},
|
|
649
624
|
};
|
|
650
625
|
|
|
651
|
-
const client =
|
|
626
|
+
const client = await ProsodyClient.create(config);
|
|
652
627
|
client.subscribe(messageHandler);
|
|
653
628
|
```
|
|
654
629
|
|
|
@@ -661,20 +636,22 @@ client.subscribe(messageHandler);
|
|
|
661
636
|
|
|
662
637
|
## Keyed State
|
|
663
638
|
|
|
664
|
-
|
|
639
|
+
Many stream transformations must reason across multiple events or timer firings. Windows, state machines, aggregates, and complex event processing all require state.
|
|
665
640
|
|
|
666
|
-
|
|
641
|
+
A Kafka key identifies an entity, such as a customer or order. Keyed state gives each key independent working state for these transformations. With Cassandra, the state survives restarts and partition reassignment.
|
|
667
642
|
|
|
668
|
-
|
|
643
|
+
Prosody selects the current message or timer key. It processes one event at a time for that key but can process other keys concurrently. By default, Prosody commits pending keyed-state changes only when the handler succeeds. If the handler returns an error, Prosody discards those changes.
|
|
644
|
+
|
|
645
|
+
Give most collections a time to live (TTL). Set the TTL beyond the longest timer or workflow that uses the collection. Omit it when state must remain for inactive keys.
|
|
669
646
|
|
|
670
647
|
### A counter for each key
|
|
671
648
|
|
|
672
|
-
Declare each collection once
|
|
649
|
+
Declare each collection once. Register it on the client. In a handler, get the current key's state from the event context:
|
|
673
650
|
|
|
674
651
|
```typescript
|
|
675
652
|
const COUNT = value<number>("count", { ttlSeconds: 30 * 24 * 60 * 60 });
|
|
676
653
|
|
|
677
|
-
const client =
|
|
654
|
+
const client = await ProsodyClient.create({
|
|
678
655
|
...config,
|
|
679
656
|
stateCollections: [COUNT],
|
|
680
657
|
});
|
|
@@ -683,15 +660,23 @@ client.subscribe({
|
|
|
683
660
|
async onMessage(context) {
|
|
684
661
|
const count = context.state(COUNT);
|
|
685
662
|
await count.set(((await count.get()) ?? 0) + 1);
|
|
663
|
+
return null;
|
|
664
|
+
},
|
|
665
|
+
async onExcise(context) {
|
|
666
|
+
await context.state(COUNT).clear();
|
|
667
|
+
return null;
|
|
686
668
|
},
|
|
669
|
+
async onTimer() {},
|
|
687
670
|
});
|
|
688
671
|
```
|
|
689
672
|
|
|
690
|
-
|
|
673
|
+
Each Kafka key now has an independent counter. A counter expires when that key has no update for 30 days.
|
|
691
674
|
|
|
692
675
|
### Window activity into one notification
|
|
693
676
|
|
|
694
|
-
This example
|
|
677
|
+
This example sends the first event for a user immediately. It collects later events for five minutes and then sends one summary.
|
|
678
|
+
|
|
679
|
+
The user ID is the Kafka key. Each user has an independent window.
|
|
695
680
|
|
|
696
681
|
```typescript
|
|
697
682
|
const WINDOW = value<boolean>("window", { ttlSeconds: 24 * 60 * 60 });
|
|
@@ -707,12 +692,13 @@ const handler = {
|
|
|
707
692
|
|
|
708
693
|
if (await window.get()) {
|
|
709
694
|
await pending.push(message);
|
|
710
|
-
return;
|
|
695
|
+
return null;
|
|
711
696
|
}
|
|
712
697
|
|
|
713
698
|
await notify(message.key, [message]);
|
|
714
699
|
await window.set(true);
|
|
715
700
|
await context.clearAndSchedule(new Date(Date.now() + 5 * 60_000));
|
|
701
|
+
return null;
|
|
716
702
|
},
|
|
717
703
|
|
|
718
704
|
async onTimer(context, timer) {
|
|
@@ -724,6 +710,12 @@ const handler = {
|
|
|
724
710
|
await pending.clear();
|
|
725
711
|
await context.state(WINDOW).clear();
|
|
726
712
|
},
|
|
713
|
+
async onExcise(context) {
|
|
714
|
+
await context.state(PENDING).clear();
|
|
715
|
+
await context.state(WINDOW).clear();
|
|
716
|
+
await context.clearScheduled();
|
|
717
|
+
return null;
|
|
718
|
+
},
|
|
727
719
|
} satisfies EventHandler<Activity>;
|
|
728
720
|
```
|
|
729
721
|
|
|
@@ -731,18 +723,18 @@ See the complete, type-checked example for imports, types, client setup, and `no
|
|
|
731
723
|
|
|
732
724
|
Why this works:
|
|
733
725
|
|
|
734
|
-
- Register both definitions in `stateCollections` before
|
|
726
|
+
- Register both definitions in `stateCollections` before you subscribe. Keyed state uses Cassandra unless `mock: true`.
|
|
735
727
|
- Use `clearAndSchedule`, not `schedule`, so a retried event does not add another timer for the same key.
|
|
736
|
-
- `capacity: 100` and the one-day TTL
|
|
737
|
-
- A `messageDeque` requires the original Kafka messages
|
|
728
|
+
- `capacity: 100` and the one-day TTL bound the saved backlog. Overflow drops the oldest message because this example only pushes.
|
|
729
|
+
- A `messageDeque` requires the original Kafka messages during the window. Use `deque` when topic retention or compaction cannot provide them.
|
|
738
730
|
- Prosody runs one handler at a time for each key, so a user's message and timer handlers cannot overlap.
|
|
739
|
-
-
|
|
731
|
+
- A notification is outside the state transaction. A retry can send it again. Use a stable operation ID to reject duplicate notifications.
|
|
740
732
|
|
|
741
733
|
### Collections and handles
|
|
742
734
|
|
|
743
|
-
A definition
|
|
735
|
+
A definition sets a collection's durable name, kind, and options. Register it once. Pass it to `context.state` in a handler.
|
|
744
736
|
|
|
745
|
-
Create handles inside the handler
|
|
737
|
+
Do not reuse a durable name for a different collection kind or payload type. Create handles inside the handler. Do not retain handles or iterators.
|
|
746
738
|
|
|
747
739
|
| Collection | JSON payload | Kafka message | Main operations |
|
|
748
740
|
| ------------------ | ------------ | ----------------- | -------------------------------------------------------------------- |
|
|
@@ -750,17 +742,57 @@ Create handles inside the handler and do not retain them or their iterators afte
|
|
|
750
742
|
| Ordered string map | `map<V>` | `messageMap<P>` | `get`, `getMany`, `has`, `set`, `delete`, `entries`, `keys`, `clear` |
|
|
751
743
|
| Deque | `deque<T>` | `messageDeque<P>` | `push`, `unshift`, `pop`, `shift`, `at`, `length`, `values`, `clear` |
|
|
752
744
|
|
|
753
|
-
All operations are
|
|
745
|
+
All operations are asynchronous. Map and deque scans are asynchronous iterables. A `for await` loop can stop early safely.
|
|
746
|
+
|
|
747
|
+
Map keys are strings. `null` and `undefined` mean absence. Do not store these values. Use `clear()` or `delete()`.
|
|
748
|
+
|
|
749
|
+
### When keyed-state changes become visible
|
|
750
|
+
|
|
751
|
+
By default, retries do not see pending state from a failed attempt. Reads in a handler see its earlier keyed-state writes. Prosody commits pending changes when the event succeeds and discards them when the handler throws.
|
|
752
|
+
|
|
753
|
+
This transaction applies only to keyed state. Some workflows need state changes before the handler ends, so each collection also provides explicit controls:
|
|
754
|
+
|
|
755
|
+
- `readUncommitted: true` persists keyed-state changes before Prosody records the event as complete. If the process stops between these steps, Prosody can process the same event again. The retry sees state changes from the earlier attempt. You must make these keyed-state changes idempotent. Each retry must produce the same state.
|
|
756
|
+
- `commit()` commits the collection's pending changes before the handler ends. A later handler failure does not remove them.
|
|
757
|
+
- `rollback()` discards pending changes since the last `commit()`. It cannot undo committed changes.
|
|
758
|
+
|
|
759
|
+
### Published state
|
|
760
|
+
|
|
761
|
+
Some callers need only the current value for a key. They can accept a stale value or a race with a concurrent update.
|
|
762
|
+
|
|
763
|
+
Use topics and event sourcing when a consumer must process each state change in order. Use published state for direct, read-only lookup of persisted keyed state. The caller does not need to consume the owner's topics or maintain a separate lookup store.
|
|
764
|
+
|
|
765
|
+
Configure the subsystem name on each publisher. Enable publication on the collection definition. Register the definition on the Prosody client:
|
|
766
|
+
|
|
767
|
+
```js
|
|
768
|
+
const CURRENT_ORDER = value("current-order", { published: true });
|
|
769
|
+
const owner = await ProsodyClient.create({
|
|
770
|
+
...config,
|
|
771
|
+
subsystem: "checkout",
|
|
772
|
+
stateCollections: [CURRENT_ORDER],
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
// The handler uses the key from its current event.
|
|
776
|
+
const currentOrder = context.state(CURRENT_ORDER);
|
|
777
|
+
await currentOrder.set({ sku: "book" });
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
Read published state from a handler or other application code. The Prosody client does not need an active subscription.
|
|
781
|
+
|
|
782
|
+
Use the subsystem and the same definition to open a reader:
|
|
783
|
+
|
|
784
|
+
```js
|
|
785
|
+
const orderReader = await client.state("checkout", CURRENT_ORDER);
|
|
786
|
+
const currentOrder = await orderReader.get("customer-123");
|
|
787
|
+
```
|
|
754
788
|
|
|
755
|
-
|
|
789
|
+
The reader cannot see pending changes that exist only in a handler. It cannot change the collection. Each read takes an explicit key because no handler supplies one.
|
|
756
790
|
|
|
757
|
-
|
|
791
|
+
Map and deque readers fetch data in chunks. They do not load the complete collection before iteration starts.
|
|
758
792
|
|
|
759
|
-
|
|
793
|
+
The default cache window is five seconds. Set `readCache: { ttlMs }` to select a different window. Set `readCache: false` to bypass the cache.
|
|
760
794
|
|
|
761
|
-
|
|
762
|
-
- `commit()` immediately publishes this collection's pending changes. They remain visible even if the handler later throws and the event is retried.
|
|
763
|
-
- `rollback()` discards this collection's pending changes since its last `commit()`. It cannot undo changes that were already committed.
|
|
795
|
+
To stop publication, deploy the definition with `published: false`. Keep the definition registered during that deployment. Keep the subsystem configured during that deployment.
|
|
764
796
|
|
|
765
797
|
## OpenTelemetry Tracing
|
|
766
798
|
|
|
@@ -814,6 +846,8 @@ OTEL_SERVICE_NAME=my-service-name
|
|
|
814
846
|
For more information on these and other OpenTelemetry environment variables, refer to
|
|
815
847
|
the [OpenTelemetry specification](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#general-sdk-configuration).
|
|
816
848
|
|
|
849
|
+
Call `flushTelemetry()` to export pending telemetry. Call `shutdownTelemetry()` before the process exits.
|
|
850
|
+
|
|
817
851
|
### Using Tracing in Your Application
|
|
818
852
|
|
|
819
853
|
After initializing tracing, you can define spans in your application, and they will be properly propagated through
|
|
@@ -825,7 +859,7 @@ const opentelemetry = require("@opentelemetry/api");
|
|
|
825
859
|
|
|
826
860
|
const tracer = opentelemetry.trace.getTracer("my-service-name");
|
|
827
861
|
|
|
828
|
-
const client =
|
|
862
|
+
const client = await ProsodyClient.create({
|
|
829
863
|
groupId: "my-consumer-group",
|
|
830
864
|
subscribedTopics: "my-topic",
|
|
831
865
|
});
|
|
@@ -841,7 +875,10 @@ const messageHandler = {
|
|
|
841
875
|
} finally {
|
|
842
876
|
span.end();
|
|
843
877
|
}
|
|
878
|
+
return null;
|
|
844
879
|
},
|
|
880
|
+
onExcise: async () => null,
|
|
881
|
+
onTimer: async () => {},
|
|
845
882
|
};
|
|
846
883
|
|
|
847
884
|
client.subscribe(messageHandler);
|
|
@@ -896,45 +933,43 @@ Strategies for achieving idempotence:
|
|
|
896
933
|
- Each message advances the state machine, allowing for idempotent processing and easy failure recovery.
|
|
897
934
|
- Particularly useful for complex, distributed transactions across multiple services.
|
|
898
935
|
|
|
899
|
-
###
|
|
936
|
+
### Application shutdown
|
|
937
|
+
|
|
938
|
+
A Prosody client runs a subscription, timers, and other services in the background. Before an application terminates, it must stop all client services. `unsubscribe()` stops only the active subscription.
|
|
900
939
|
|
|
901
|
-
|
|
940
|
+
Call `shutdown()` when the application terminates. It stops all client services and rejects new operations. Call `unsubscribe()` only when the application will use the client again. You do not need to call `unsubscribe()` before `shutdown()`.
|
|
902
941
|
|
|
903
942
|
```javascript
|
|
904
|
-
|
|
905
|
-
await client.unsubscribe();
|
|
943
|
+
await client.shutdown();
|
|
906
944
|
```
|
|
907
945
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
1. Completion and commitment of all in-flight work
|
|
911
|
-
2. Quick rebalancing, allowing other consumers to take over partitions
|
|
912
|
-
3. Proper release of resources
|
|
913
|
-
|
|
914
|
-
Implement shutdown handling in your application:
|
|
946
|
+
Handle application shutdown:
|
|
915
947
|
|
|
916
948
|
```javascript
|
|
917
949
|
const { ProsodyClient } = require("@prosody-events/prosody");
|
|
918
950
|
|
|
919
951
|
async function main() {
|
|
920
|
-
const client =
|
|
952
|
+
const client = await ProsodyClient.create({
|
|
921
953
|
groupId: "my-consumer-group",
|
|
922
954
|
subscribedTopics: "my-topic",
|
|
923
955
|
});
|
|
924
956
|
|
|
925
957
|
const messageHandler = {
|
|
926
958
|
onMessage: async (context, message, signal) => {
|
|
927
|
-
// Process the message
|
|
959
|
+
// Process the message.
|
|
960
|
+
return null;
|
|
928
961
|
},
|
|
962
|
+
onExcise: async () => null,
|
|
963
|
+
onTimer: async () => {},
|
|
929
964
|
};
|
|
930
965
|
|
|
931
966
|
client.subscribe(messageHandler);
|
|
932
967
|
|
|
933
|
-
//
|
|
968
|
+
// Resolve this promise after a shutdown signal.
|
|
934
969
|
const shutdownPromise = new Promise((resolve) => {
|
|
935
970
|
const shutdown = async (signal) => {
|
|
936
|
-
console.log(`Received ${signal}.
|
|
937
|
-
await client.
|
|
971
|
+
console.log(`Received ${signal}. Client shutdown starts.`);
|
|
972
|
+
await client.shutdown();
|
|
938
973
|
resolve();
|
|
939
974
|
};
|
|
940
975
|
|
|
@@ -943,7 +978,7 @@ async function main() {
|
|
|
943
978
|
process.on("SIGHUP", () => shutdown("SIGHUP"));
|
|
944
979
|
});
|
|
945
980
|
|
|
946
|
-
// Wait for shutdown
|
|
981
|
+
// Wait for a shutdown signal.
|
|
947
982
|
await shutdownPromise;
|
|
948
983
|
}
|
|
949
984
|
|
|
@@ -988,7 +1023,10 @@ const messageHandler = {
|
|
|
988
1023
|
|
|
989
1024
|
// Send a message, passing the abort signal
|
|
990
1025
|
await client.send("topic", "key", { data: "value" }, signal);
|
|
1026
|
+
return null;
|
|
991
1027
|
},
|
|
1028
|
+
onExcise: async () => null,
|
|
1029
|
+
onTimer: async () => {},
|
|
992
1030
|
};
|
|
993
1031
|
```
|
|
994
1032
|
|
|
@@ -1018,7 +1056,7 @@ Prosody uses an automated release process managed by GitHub Actions. Here's an o
|
|
|
1018
1056
|
- Windows build for x64.
|
|
1019
1057
|
- macOS build for aarch64 (Apple Silicon).
|
|
1020
1058
|
|
|
1021
|
-
4. **Testing**: The built binaries are tested on Linux (x86_64 and aarch64) with Node.js
|
|
1059
|
+
4. **Testing**: The built binaries are tested on Linux (x86_64 and aarch64) with Node.js 24.
|
|
1022
1060
|
|
|
1023
1061
|
5. **Artifact Upload**: Each build job uploads its artifacts (Node.js native addons) to GitHub Actions.
|
|
1024
1062
|
|
|
@@ -1048,21 +1086,37 @@ your changes before merging to `main`.
|
|
|
1048
1086
|
|
|
1049
1087
|
### ProsodyClient
|
|
1050
1088
|
|
|
1051
|
-
- `
|
|
1089
|
+
- `ProsodyClient.create(config: Configuration): Promise<ProsodyClient>`: Initialize a client without blocking the Node.js event loop.
|
|
1052
1090
|
- `send<P>(topic: string, key: string, payload: P & JsonCompatible<P>, signal?: AbortSignal): Promise<void>`: Send a statically checked JSON-compatible message to a specified
|
|
1053
1091
|
topic.
|
|
1054
|
-
- `
|
|
1092
|
+
- `excise(topic: string, key: string, signal?: AbortSignal): Promise<void>`: Send an excise record for a key.
|
|
1093
|
+
- `request<R>(topic, key, payload: JsonValue, options): Promise<ReadonlyMap<string, Outcome<R>>>`: Return one outcome for each subsystem.
|
|
1094
|
+
- `requestExcise<R>(topic, key, options): Promise<ReadonlyMap<string, Outcome<R>>>`: Return one excise outcome for each subsystem.
|
|
1095
|
+
- `consumerState(): Promise<ConsumerState>`: Get the current state of the consumer.
|
|
1096
|
+
- `assignedPartitionCount(): Promise<number>`: Get the assigned partition count.
|
|
1097
|
+
- `isStalled(): Promise<boolean>`: Test whether the consumer is stalled.
|
|
1055
1098
|
- `sourceSystem: string`: Get the source system identifier configured for the client.
|
|
1056
|
-
- `
|
|
1057
|
-
- `
|
|
1099
|
+
- `state<T>(subsystem: string, definition: ValueDefinition<T>): Promise<PublishedValue<T>>`: Open a read-only published value.
|
|
1100
|
+
- `state<V>(subsystem: string, definition: MapDefinition<V>): Promise<PublishedMap<V>>`: Open a read-only published map.
|
|
1101
|
+
- `state<T>(subsystem: string, definition: DequeDefinition<T>): Promise<PublishedDeque<T>>`: Open a read-only published deque.
|
|
1102
|
+
- `subscribe<P = JsonValue, R = JsonValue>(eventHandler: EventHandler<P, R>): Promise<void>`: Subscribe with typed payload and response values.
|
|
1103
|
+
- `unsubscribe(): Promise<void>`: Stop the consumer. You can subscribe again later.
|
|
1104
|
+
- `shutdown(): Promise<void>`: Stop all client services. Concurrent and repeated calls await the same operation.
|
|
1105
|
+
|
|
1106
|
+
### AdminClient
|
|
1107
|
+
|
|
1108
|
+
- `new AdminClient(bootstrapServers)`: Create an admin client for the specified Kafka servers.
|
|
1109
|
+
- `createTopic(name, partitions, replicationFactor)`: Create a Kafka topic.
|
|
1110
|
+
- `deleteTopic(name)`: Delete a Kafka topic.
|
|
1058
1111
|
|
|
1059
1112
|
### EventHandler
|
|
1060
1113
|
|
|
1061
1114
|
Interface for handling messages and timers:
|
|
1062
1115
|
|
|
1063
|
-
- `EventHandler<P = JsonValue>` carries the
|
|
1064
|
-
- `onMessage
|
|
1065
|
-
- `
|
|
1116
|
+
- `EventHandler<P = JsonValue, R = JsonValue>` carries the payload and response types through each callback.
|
|
1117
|
+
- `onMessage: (context, message, signal) => MaybePromise<R & JsonCompatible<R>>`: Handle incoming messages.
|
|
1118
|
+
- `onExcise: (context, message, signal) => MaybePromise<R & JsonCompatible<R>>`: Handle excise records.
|
|
1119
|
+
- `onTimer: (context, timer, signal) => MaybePromise<void>`: Handle timer events.
|
|
1066
1120
|
|
|
1067
1121
|
### Message
|
|
1068
1122
|
|
|
@@ -1077,14 +1131,17 @@ Represents a Kafka message with the following properties:
|
|
|
1077
1131
|
|
|
1078
1132
|
`Message` takes an optional payload type parameter, `Message<P>`, used by handlers and message-backed state collections to type `payload`. Unparameterized `Message` is `Message<JsonValue>`, preserving useful JSON safety without requiring an application-specific payload type.
|
|
1079
1133
|
|
|
1080
|
-
`JsonValue` describes arbitrary JSON data. `JsonCompatible<T>` checks a known
|
|
1081
|
-
|
|
1082
|
-
functions, `undefined`, `Date`, symbols, bigints, and invalid nested fields
|
|
1083
|
-
|
|
1134
|
+
`JsonValue` describes arbitrary JSON data. `JsonCompatible<T>` checks a known application type recursively.
|
|
1135
|
+
|
|
1136
|
+
Ordinary interfaces work with `send()`. TypeScript rejects functions, `undefined`, `Date`, symbols, bigints, and invalid nested fields before serialization.
|
|
1137
|
+
|
|
1138
|
+
### ExciseMessage
|
|
1139
|
+
|
|
1140
|
+
An `ExciseMessage` has `topic`, `partition`, `offset`, `timestamp`, and `key` properties. It has no `payload` property.
|
|
1084
1141
|
|
|
1085
1142
|
### Context
|
|
1086
1143
|
|
|
1087
|
-
Represents the
|
|
1144
|
+
Represents the current event context:
|
|
1088
1145
|
|
|
1089
1146
|
- `onCancel(): Promise<void>`: A method that resolves when the context is cancelled.
|
|
1090
1147
|
- `shouldCancel: boolean`: A property indicating whether the context has been cancelled.
|
|
@@ -1099,7 +1156,7 @@ Timer scheduling methods:
|
|
|
1099
1156
|
|
|
1100
1157
|
Keyed-state binding:
|
|
1101
1158
|
|
|
1102
|
-
- `state(definition): ValueState<T> | MapState<V> | DequeState<T>`:
|
|
1159
|
+
- `state(definition): ValueState<T> | MapState<V> | DequeState<T>`: Bind a registered collection for the current attempt. Message definitions return handles that contain `Message<P>`. An unregistered or mismatched definition throws `PermanentStateError`. See [Keyed State](#keyed-state-2).
|
|
1103
1160
|
|
|
1104
1161
|
### Timer
|
|
1105
1162
|
|
|
@@ -1108,18 +1165,41 @@ Represents a timer that has fired, provided to the `onTimer` method:
|
|
|
1108
1165
|
- `key: string`: The entity key identifying what this timer belongs to
|
|
1109
1166
|
- `time: Date`: The time when this timer was scheduled to fire
|
|
1110
1167
|
|
|
1168
|
+
### Requests
|
|
1169
|
+
|
|
1170
|
+
- `RequestOptions`: Contains `subsystems`, `timeoutMs`, and an optional `signal`.
|
|
1171
|
+
- `Outcome<T>`: A `Success<T>` or `Failure` result for one subsystem.
|
|
1172
|
+
- `Success<T>`: Contains `ok: true` and `value: T`.
|
|
1173
|
+
- `Failure`: Contains `ok: false` and a `ResponseError`.
|
|
1174
|
+
- `ResponseError`: A handler, timeout, format-mismatch, or malformed-response error.
|
|
1175
|
+
|
|
1176
|
+
### Configuration types
|
|
1177
|
+
|
|
1178
|
+
- `Configuration`: Contains the client settings. See [Configuration](CONFIGURATION.md).
|
|
1179
|
+
- `ConsumerState`: Identifies the consumer lifecycle state.
|
|
1180
|
+
- `Mode`: Selects the client processing mode.
|
|
1181
|
+
- `ReadCacheConfiguration`: Configures the default published-state cache.
|
|
1182
|
+
- `ReadCacheOptions`: Overrides the cache for one published collection.
|
|
1183
|
+
|
|
1184
|
+
### Payload types
|
|
1185
|
+
|
|
1186
|
+
- `JsonPrimitive`: A JSON null, boolean, number, or string.
|
|
1187
|
+
- `JsonValue`: Any recursively JSON-compatible value.
|
|
1188
|
+
- `JsonCompatible<T>`: Rejects non-JSON members in a known application type.
|
|
1189
|
+
- `MaybePromise<T>`: A value or a promise-like value.
|
|
1190
|
+
|
|
1111
1191
|
### Keyed State
|
|
1112
1192
|
|
|
1113
1193
|
Definition constructors (each returns a frozen definition object used both in `Configuration.stateCollections` and with `context.state()`):
|
|
1114
1194
|
|
|
1115
|
-
- `value<T = JsonValue>(name: string, options?:
|
|
1195
|
+
- `value<T = JsonValue>(name: string, options?: PublishedStateDefinitionOptions): ValueDefinition<T>`
|
|
1116
1196
|
- `map<V = JsonValue>(name: string, options?: MapDefinitionOptions): MapDefinition<V>`
|
|
1117
|
-
- `deque<T = JsonValue>(name: string, options?:
|
|
1197
|
+
- `deque<T = JsonValue>(name: string, options?: DequeDefinitionOptions): DequeDefinition<T>`
|
|
1118
1198
|
- `messageValue<P = JsonValue>(name: string, options?: StateDefinitionOptions): MessageValueDefinition<P>`
|
|
1119
|
-
- `messageMap<P = JsonValue>(name: string, options?:
|
|
1120
|
-
- `messageDeque<P = JsonValue>(name: string, options?:
|
|
1199
|
+
- `messageMap<P = JsonValue>(name: string, options?: MessageMapDefinitionOptions): MessageMapDefinition<P>`
|
|
1200
|
+
- `messageDeque<P = JsonValue>(name: string, options?: MessageDequeDefinitionOptions): MessageDequeDefinition<P>`
|
|
1121
1201
|
|
|
1122
|
-
`StateDefinitionOptions`: `{ ttlSeconds?: number; readUncommitted?: boolean }`. `
|
|
1202
|
+
`StateDefinitionOptions`: `{ ttlSeconds?: number; readUncommitted?: boolean }`. `PublishedStateDefinitionOptions` adds `{ published?: boolean; readCache?: { ttlMs: number } | false }` for JSON definitions. Map and deque option types add `keysetLimit` and `capacity`, respectively; their message equivalents omit publication options.
|
|
1123
1203
|
|
|
1124
1204
|
`ValueState<T>`:
|
|
1125
1205
|
|
|
@@ -1161,14 +1241,41 @@ Definition constructors (each returns a frozen definition object used both in `C
|
|
|
1161
1241
|
|
|
1162
1242
|
`ScanDirection`: `"forward" | "backward"`.
|
|
1163
1243
|
|
|
1164
|
-
|
|
1244
|
+
Published readers take the user key as their first argument. `PublishedValue<T>` provides `get`. `PublishedMap<V>` provides `get`, `getMany`, `has`, `entries`, `keys`, and `values`. `PublishedDeque<T>` provides `at`, `length`, `isEmpty`, and `values`. The scan methods return `AsyncIterableIterator` directly.
|
|
1245
|
+
|
|
1246
|
+
`StateCollectionConfig` defines one `stateCollections` entry. It contains `name`, `kind`, `payload`, and the applicable collection options. Use a definition constructor to create this value.
|
|
1247
|
+
|
|
1248
|
+
JSON definitions also accept `readCache`. This option applies when the definition opens published state. It is not part of `StateCollectionConfig`.
|
|
1249
|
+
|
|
1250
|
+
The public definition types are `ValueDefinition<T>`, `MapDefinition<V>`, `DequeDefinition<T>`, `MessageValueDefinition<P>`, `MessageMapDefinition<P>`, and `MessageDequeDefinition<P>`.
|
|
1251
|
+
|
|
1252
|
+
All definitions expose `name`, `kind`, `payload`, `ttlSeconds`, and `readUncommitted`. JSON definitions also expose `published` and `readCache`. Map definitions expose `keysetLimit`. Deque definitions expose `capacity`.
|
|
1165
1253
|
|
|
1166
1254
|
Errors:
|
|
1167
1255
|
|
|
1168
|
-
- `TransientStateError extends TransientError`:
|
|
1169
|
-
- `PermanentStateError extends PermanentError`:
|
|
1256
|
+
- `TransientStateError extends TransientError`: Reports a keyed-state error that Prosody can retry.
|
|
1257
|
+
- `PermanentStateError extends PermanentError`: Reports a keyed-state error that another attempt cannot resolve.
|
|
1170
1258
|
- `isStateError(error: unknown): error is PermanentStateError | TransientStateError`: type-guard narrowing an error to either state error class.
|
|
1171
1259
|
|
|
1260
|
+
Handler error types and decorators:
|
|
1261
|
+
|
|
1262
|
+
- `EventHandlerError`: Base class with an abstract `isPermanent` property.
|
|
1263
|
+
- `TransientError`: Has `isPermanent: false` and marks an error as retriable.
|
|
1264
|
+
- `PermanentError`: Has `isPermanent: true` and marks an error as final.
|
|
1265
|
+
- `transient(...errorTypes)`: Creates a transient-error decorator.
|
|
1266
|
+
- `permanent(...errorTypes)`: Creates a permanent-error decorator.
|
|
1267
|
+
|
|
1268
|
+
### Logging and telemetry
|
|
1269
|
+
|
|
1270
|
+
- `Logger`: Provides `error`, `warn`, `info`, `debug`, and `trace` methods.
|
|
1271
|
+
- `initialize()`: Prepare the logging and tracing system during application startup.
|
|
1272
|
+
- `loggerIsSet()`: Test whether the application configured a logger.
|
|
1273
|
+
- `setLogger(logger)`: Replaces the logger.
|
|
1274
|
+
- `setLoggerIfUnset(logger)`: Sets the logger only when no logger exists.
|
|
1275
|
+
- `getCurrentLogger()`: Returns the current JavaScript logger.
|
|
1276
|
+
- `flushTelemetry()`: Exports pending telemetry.
|
|
1277
|
+
- `shutdownTelemetry()`: Exports pending telemetry and stops its providers.
|
|
1278
|
+
|
|
1172
1279
|
## License
|
|
1173
1280
|
|
|
1174
1281
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|