prosody 0.3.0 → 0.5.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.cargo/config.toml +3 -0
  3. data/.release-please-manifest.json +1 -1
  4. data/AGENTS.md +395 -0
  5. data/ARCHITECTURE.md +14 -4
  6. data/CHANGELOG.md +28 -0
  7. data/CLAUDE.md +1 -0
  8. data/CONFIGURATION.md +167 -0
  9. data/Cargo.lock +1115 -645
  10. data/Cargo.toml +7 -6
  11. data/README.md +436 -146
  12. data/Rakefile +11 -1
  13. data/examples/keyed_state.rb +70 -0
  14. data/examples/keyed_state.rbs +18 -0
  15. data/examples/keyed_state_windowing.rb +55 -0
  16. data/examples/keyed_state_windowing.rbs +16 -0
  17. data/ext/prosody/Cargo.toml +1 -0
  18. data/ext/prosody/src/admin.rs +1 -5
  19. data/ext/prosody/src/bridge/mod.rs +17 -32
  20. data/ext/prosody/src/client/config.rs +501 -28
  21. data/ext/prosody/src/client/mod.rs +167 -74
  22. data/ext/prosody/src/client/request.rs +132 -0
  23. data/ext/prosody/src/client/support.rs +122 -0
  24. data/ext/prosody/src/handler/context.rs +150 -5
  25. data/ext/prosody/src/handler/message.rs +67 -0
  26. data/ext/prosody/src/handler/mod.rs +115 -85
  27. data/ext/prosody/src/handler/state/mod.rs +488 -0
  28. data/ext/prosody/src/handler/state/registration.rs +104 -0
  29. data/ext/prosody/src/handler/state/scan.rs +218 -0
  30. data/ext/prosody/src/lib.rs +15 -3
  31. data/ext/prosody/src/published.rs +273 -0
  32. data/ext/prosody/src/scheduler/mod.rs +2 -2
  33. data/ext/prosody/src/scheduler/processor.rs +2 -2
  34. data/ext/prosody/src/scheduler/result.rs +7 -4
  35. data/ext/prosody/src/util.rs +86 -5
  36. data/lib/prosody/configuration.rb +71 -11
  37. data/lib/prosody/handler.rb +65 -8
  38. data/lib/prosody/native_stubs.rb +550 -9
  39. data/lib/prosody/request.rb +45 -0
  40. data/lib/prosody/state.rb +816 -0
  41. data/lib/prosody/version.rb +1 -1
  42. data/lib/prosody.rb +6 -0
  43. data/release-please-config.json +4 -0
  44. data/sig/configuration.rbs +70 -11
  45. data/sig/handler.rbs +17 -5
  46. data/sig/processor.rbs +28 -12
  47. data/sig/prosody.rbs +53 -7
  48. data/sig/request.rbs +66 -0
  49. data/sig/sentry.rbs +6 -0
  50. data/sig/state.rbs +390 -0
  51. data/steep_expectations.yml +57 -0
  52. data/typecheck/payload_types.rb +54 -0
  53. data/typecheck/payload_types.rbs +22 -0
  54. data/typecheck_negative/payload_types.rb +20 -0
  55. data/typecheck_negative/payload_types.rbs +9 -0
  56. metadata +32 -9
data/README.md CHANGED
@@ -9,6 +9,7 @@ strategies, and integrated OpenTelemetry support for distributed tracing.
9
9
  - **Kafka Consumer**: Per-key ordering with cross-key concurrency, offset management, consumer groups
10
10
  - **Kafka Producer**: Idempotent delivery with configurable retries
11
11
  - **Timer System**: Persistent scheduled execution backed by Cassandra or in-memory store
12
+ - **Keyed State**: Per-key value/map/deque collections that survive across events, transactional by default
12
13
  - **Quality of Service**: Fair scheduling limits concurrency and prevents failures from starving fresh traffic. Pipeline mode adds deferred retry and monopolization detection
13
14
  - **Distributed Tracing**: OpenTelemetry integration for tracing message flow across services
14
15
  - **Backpressure**: Pauses partitions when handlers fall behind
@@ -29,6 +30,12 @@ Or install directly:
29
30
  gem install prosody
30
31
  ```
31
32
 
33
+ The gem ships RBS signatures for the public API. `Prosody::EventHandler[Payload, Response]`
34
+ carries the payload type into `Prosody::Message[Payload]`. It also checks each handler response.
35
+ State definitions carry their item types through `context.state`. A bare handler,
36
+ message, definition, or state handle uses `Prosody::json_value`. See the
37
+ [typed examples](examples/) for Ruby and companion RBS files checked by Steep.
38
+
32
39
  ## Quick Start
33
40
 
34
41
  ```ruby
@@ -52,6 +59,12 @@ client = Prosody::Client.new(
52
59
 
53
60
  # Define a custom message handler
54
61
  class MyHandler < Prosody::EventHandler
62
+ def on_excise(context, message)
63
+ puts "Excise key: #{message.key}"
64
+ context.clear_scheduled
65
+ nil
66
+ end
67
+
55
68
  def on_message(context, message)
56
69
  # Process the received message
57
70
  puts "Received message: #{message.payload.inspect}"
@@ -73,12 +86,23 @@ end
73
86
  client.subscribe(MyHandler.new)
74
87
 
75
88
  # Send a message to a topic
76
- client.send_message("my-topic", "message-key", { content: "Hello, Kafka!" })
89
+ client.send_message("my-topic", "message-key", {"content" => "Hello, Kafka!"})
90
+ client.excise("my-topic", "obsolete-key")
77
91
 
78
92
  # Ensure proper shutdown when done
79
- client.unsubscribe
93
+ client.shutdown
80
94
  ```
81
95
 
96
+ ## Excise records
97
+
98
+ Applications can copy event data into keyed state and external stores. A regulatory or contractual deletion must remove every copy for one key.
99
+
100
+ 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 `on_excise`. The handler must delete all consumer-owned data for the key.
101
+
102
+ Each handler must implement `on_message`, `on_excise`, and `on_timer`. Subscription fails before consumption if a method is missing.
103
+
104
+ If an excise record is a request, return a response from `on_excise`. Prosody uses this response as the subsystem result.
105
+
82
106
  ## Architecture
83
107
 
84
108
  Prosody enables efficient, parallel processing of Kafka messages while maintaining order for messages with the same key:
@@ -166,109 +190,9 @@ timeout defaults to 80% of `stall_threshold`.
166
190
 
167
191
  ## Configuration
168
192
 
169
- Configure via constructor options or environment variables. Options fall back to environment variables when unset.
170
-
171
- ### Core
172
-
173
- | Option / Environment Variable | Description | Default |
174
- |-----------------------------------------|---------------------------------------------------|--------------|
175
- | `bootstrap_servers` / `PROSODY_BOOTSTRAP_SERVERS` | Kafka servers to connect to | - |
176
- | `group_id` / `PROSODY_GROUP_ID` | Consumer group name | - |
177
- | `subscribed_topics` / `PROSODY_SUBSCRIBED_TOPICS` | Topics to read from | - |
178
- | `allowed_events` / `PROSODY_ALLOWED_EVENTS` | Only process events matching these prefixes | (all) |
179
- | `source_system` / `PROSODY_SOURCE_SYSTEM` | Tag for outgoing messages (prevents reprocessing)| `<group_id>` |
180
- | `mock` / `PROSODY_MOCK` | Use in-memory Kafka for testing | false |
181
-
182
- ### Consumer
183
-
184
- | Option / Environment Variable | Description | Default |
185
- |-----------------------------------------|------------------------------------------------------|------------------------|
186
- | `max_concurrency` / `PROSODY_MAX_CONCURRENCY` | Max messages being processed simultaneously | 32 |
187
- | `max_uncommitted` / `PROSODY_MAX_UNCOMMITTED` | Max queued messages before pausing consumption | 64 |
188
- | `timeout` / `PROSODY_TIMEOUT` | Cancel handler if it runs longer than this | 80% of stall threshold |
189
- | `commit_interval` / `PROSODY_COMMIT_INTERVAL` | How often to save progress to Kafka | 1s |
190
- | `poll_interval` / `PROSODY_POLL_INTERVAL` | How often to fetch new messages from Kafka | 100ms |
191
- | `shutdown_timeout` / `PROSODY_SHUTDOWN_TIMEOUT` | Shutdown budget; handlers run freely until cancellation fires near the end of the timeout | 30s |
192
- | `stall_threshold` / `PROSODY_STALL_THRESHOLD` | Report unhealthy if no progress for this long | 5m |
193
- | `probe_port` / `PROSODY_PROBE_PORT` | HTTP port for health checks (nil to disable) | 8000 |
194
- | `failure_topic` / `PROSODY_FAILURE_TOPIC` | Send unprocessable messages here (dead letter queue) | - |
195
- | `idempotence_cache_size` / `PROSODY_IDEMPOTENCE_CACHE_SIZE` | Global shared cache capacity across all partitions for message deduplication (0 disables the entire deduplication middleware, both in-memory and persistent) | 8192 |
196
- | `idempotence_version` / `PROSODY_IDEMPOTENCE_VERSION` | Version string for cache-busting dedup hashes | 1 |
197
- | `idempotence_ttl` / `PROSODY_IDEMPOTENCE_TTL` | TTL for dedup records in Cassandra | 7d (604800 seconds) |
198
- | `slab_size` / `PROSODY_SLAB_SIZE` | Timer storage granularity (rarely needs changing) | 1h |
199
- | `message_spans` / `PROSODY_MESSAGE_SPANS` | Span linking for message execution: `child` (child-of) or `follows_from` | `child` |
200
- | `timer_spans` / `PROSODY_TIMER_SPANS` | Span linking for timer execution: `child` (child-of) or `follows_from` | `follows_from` |
201
-
202
- ### Producer
203
-
204
- | Option / Environment Variable | Description | Default |
205
- |-----------------------------------------|---------------------------------|---------|
206
- | `send_timeout` / `PROSODY_SEND_TIMEOUT` | Give up sending after this long | 1s |
207
-
208
- ### Retry
209
-
210
- When a handler fails, retry with exponential backoff:
211
-
212
- | Option / Environment Variable | Description | Default |
213
- |-----------------------------------------|-----------------------------------|---------|
214
- | `max_retries` / `PROSODY_MAX_RETRIES` | Give up after this many attempts | 3 |
215
- | `retry_base` / `PROSODY_RETRY_BASE` | Wait this long before first retry | 20ms |
216
- | `max_retry_delay` / `PROSODY_RETRY_MAX_DELAY` | Never wait longer than this | 5m |
217
-
218
- ### Deferral (Pipeline Mode)
219
-
220
- | Option / Environment Variable | Description | Default |
221
- |-----------------------------------------|---------------------------------------------------|---------|
222
- | `defer_enabled` / `PROSODY_DEFER_ENABLED` | Enable deferral for new messages | true |
223
- | `defer_base` / `PROSODY_DEFER_BASE` | Wait this long before first deferred retry | 1s |
224
- | `defer_max_delay` / `PROSODY_DEFER_MAX_DELAY` | Never wait longer than this | 24h |
225
- | `defer_failure_threshold` / `PROSODY_DEFER_FAILURE_THRESHOLD` | Disable deferral when failure rate exceeds this | 0.9 |
226
- | `defer_failure_window` / `PROSODY_DEFER_FAILURE_WINDOW` | Measure failure rate over this time window | 5m |
227
- | `defer_cache_size` / `PROSODY_DEFER_CACHE_SIZE` | Track this many deferred keys in memory | 1024 |
228
- | `defer_store_cache_size` / `PROSODY_DEFER_STORE_CACHE_SIZE` | Maximum deferred store cache entries per Cassandra defer store | 8192 |
229
- | `defer_seek_timeout` / `PROSODY_DEFER_SEEK_TIMEOUT` | Timeout when loading deferred messages | 30s |
230
- | `defer_discard_threshold` / `PROSODY_DEFER_DISCARD_THRESHOLD` | Read optimization (rarely needs changing) | 100 |
231
-
232
- ### Monopolization Detection (Pipeline Mode)
233
-
234
- | Option / Environment Variable | Description | Default |
235
- |-----------------------------------------|-----------------------------------------|---------|
236
- | `monopolization_enabled` / `PROSODY_MONOPOLIZATION_ENABLED` | Enable hot key protection | true |
237
- | `monopolization_threshold` / `PROSODY_MONOPOLIZATION_THRESHOLD` | Max handler time as fraction of window | 0.9 |
238
- | `monopolization_window` / `PROSODY_MONOPOLIZATION_WINDOW` | Measurement window | 5m |
239
- | `monopolization_cache_size` / `PROSODY_MONOPOLIZATION_CACHE_SIZE` | Max distinct keys to track | 8192 |
193
+ For the complete configuration reference, see [CONFIGURATION.md](CONFIGURATION.md).
240
194
 
241
- ### Fair Scheduling (All Modes)
242
-
243
- | Option / Environment Variable | Description | Default |
244
- |-----------------------------------------|------------------------------------------------------------------|---------|
245
- | `scheduler_failure_weight` / `PROSODY_SCHEDULER_FAILURE_WEIGHT` | Fraction of processing time reserved for retries | 0.3 |
246
- | `scheduler_max_wait` / `PROSODY_SCHEDULER_MAX_WAIT` | Messages waiting this long get maximum priority | 2m |
247
- | `scheduler_wait_weight` / `PROSODY_SCHEDULER_WAIT_WEIGHT` | Priority boost for waiting messages (higher = more aggressive) | 200.0 |
248
- | `scheduler_cache_size` / `PROSODY_SCHEDULER_CACHE_SIZE` | Max distinct keys to track | 8192 |
249
-
250
- ### Cassandra
251
-
252
- Persistent storage for timers and deferred retries (not needed if `mock: true`):
253
-
254
- | Option / Environment Variable | Description | Default |
255
- |-----------------------------------------|------------------------------------|---------|
256
- | `cassandra_nodes` / `PROSODY_CASSANDRA_NODES` | Servers to connect to (host:port) | - |
257
- | `cassandra_keyspace` / `PROSODY_CASSANDRA_KEYSPACE` | Keyspace name | prosody |
258
- | `cassandra_user` / `PROSODY_CASSANDRA_USER` | Username | - |
259
- | `cassandra_password` / `PROSODY_CASSANDRA_PASSWORD` | Password | - |
260
- | `cassandra_datacenter` / `PROSODY_CASSANDRA_DATACENTER` | Prefer this datacenter for queries | - |
261
- | `cassandra_rack` / `PROSODY_CASSANDRA_RACK` | Prefer this rack for queries | - |
262
- | `cassandra_retention` / `PROSODY_CASSANDRA_RETENTION` | Delete data older than this | 1y |
263
-
264
- ### Telemetry Emitter
265
-
266
- Prosody can emit internal processing events (message lifecycle, timer events) to a Kafka topic for observability:
267
-
268
- | Option / Environment Variable | Description | Default |
269
- |-----------------------------------------|------------------------------------------------|----------------------------|
270
- | `telemetry_topic` / `PROSODY_TELEMETRY_TOPIC` | Kafka topic to produce telemetry events to | `prosody.telemetry-events` |
271
- | `telemetry_enabled` / `PROSODY_TELEMETRY_ENABLED` | Enable or disable the telemetry emitter | true |
195
+ Constructor options take precedence. Unset options use environment variables, then library defaults.
272
196
 
273
197
  ## Logging
274
198
 
@@ -361,6 +285,73 @@ if client.is_stalled?
361
285
  end
362
286
  ```
363
287
 
288
+ ## Subsystems
289
+
290
+ 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.
291
+
292
+ 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.
293
+
294
+ ## Requests
295
+
296
+ 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.
297
+
298
+ Send a request from a handler or other application code. The Prosody client does not need an active subscription. The result hash uses canonical subsystem names as keys. Each value is a `Success` or `Failure` outcome. Use `request_excise` to send an excise record and collect the same outcome type.
299
+
300
+ Do not rely on hash order. The hash contains one entry for each selected subsystem. A missing response becomes a timeout `Failure`; Prosody does not omit the subsystem. The request raises an error for request-level failures, such as invalid input, a Kafka send failure, or shutdown. Do not wait for a request if the current consumer group must process it for the same key. That group cannot process it until the handler returns.
301
+
302
+ Message and excise handler return values become successful outcomes. Each return value must have a JSON representation.
303
+
304
+ Set `subsystem` to `inventory` on the client that subscribes this handler.
305
+
306
+ ```ruby
307
+ class InventoryHandler < Prosody::EventHandler
308
+ def on_message(_context, message)
309
+ {"accepted" => message.key}
310
+ end
311
+
312
+ def on_excise(_context, message)
313
+ {"excised" => message.key}
314
+ end
315
+
316
+ def on_timer(_context, _timer)
317
+ end
318
+ end
319
+ ```
320
+
321
+ Send the request:
322
+
323
+ Set `timeout` in seconds.
324
+
325
+ ```ruby
326
+ subsystems = ["inventory", "billing"]
327
+ results = client.request(
328
+ topic: "orders",
329
+ key: "order-1",
330
+ payload: {"type" => "order.created"},
331
+ subsystems: subsystems,
332
+ timeout: 2.0
333
+ )
334
+
335
+ results.each do |subsystem, outcome|
336
+ if outcome.is_a?(Prosody::Failure)
337
+ warn "#{subsystem}: #{outcome.error.message}"
338
+ else
339
+ puts "#{subsystem}: #{outcome.value}"
340
+ end
341
+ end
342
+ ```
343
+
344
+ The example can print these results:
345
+
346
+ ```text
347
+ inventory: {"accepted"=>"order-1"}
348
+ billing: no response arrived before the deadline
349
+ ```
350
+
351
+ Each failure contains one typed response error.
352
+
353
+ Each response error has one message.
354
+
364
355
  ## Advanced Usage
365
356
 
366
357
  ### Pipeline Mode
@@ -476,36 +467,37 @@ The deduplication system uses:
476
467
  ```ruby
477
468
  # Messages with IDs are deduplicated per key
478
469
  client.send_message("my-topic", "key1", {
479
- id: "msg-123", # Message will be processed
480
- content: "Hello!"
470
+ "id" => "msg-123", # Message will be processed
471
+ "content" => "Hello!"
481
472
  })
482
473
 
483
474
  client.send_message("my-topic", "key1", {
484
- id: "msg-123", # Message will be skipped (duplicate)
485
- content: "Hello again!"
475
+ "id" => "msg-123", # Message will be skipped (duplicate)
476
+ "content" => "Hello again!"
486
477
  })
487
478
 
488
479
  client.send_message("my-topic", "key2", {
489
- id: "msg-123", # Message will be processed (different key)
490
- content: "Hello!"
480
+ "id" => "msg-123", # Message will be processed (different key)
481
+ "content" => "Hello!"
491
482
  })
492
483
  ```
493
484
 
494
- Setting `idempotence_cache_size` to `0` disables the **entire** deduplication middleware (both the in-memory cache and the Cassandra-backed persistent store):
485
+ Consumer deduplication is **mandatory** it is the commit oracle that makes
486
+ keyed state correct — so it cannot be disabled. `idempotence_cache_size` must be
487
+ at least 1; setting it to `0` in the client configuration raises an
488
+ `ArgumentError`:
495
489
 
496
490
  ```ruby
497
491
  client = Prosody::Client.new(
498
492
  group_id: "my-consumer-group",
499
493
  subscribed_topics: "my-topic",
500
- idempotence_cache_size: 0 # Disable all deduplication (both in-memory and persistent)
494
+ idempotence_cache_size: 0 # Rejected: consumer deduplication cannot be disabled
501
495
  )
502
496
  ```
503
497
 
504
- Or via environment variable:
505
-
506
- ```bash
507
- PROSODY_IDEMPOTENCE_CACHE_SIZE=0
508
- ```
498
+ This applies to every client — `Prosody::Client.new` always builds a consumer, so
499
+ `0` is rejected regardless of whether any topics are subscribed, and whether it is
500
+ supplied in the client configuration or via `PROSODY_IDEMPOTENCE_CACHE_SIZE`.
509
501
 
510
502
  To invalidate all previously recorded dedup entries (e.g. after a data migration), change the version string:
511
503
 
@@ -529,6 +521,169 @@ client = Prosody::Client.new(
529
521
 
530
522
  Note that the in-memory cache is best-effort. Duplicates can still occur across different process instances.
531
523
 
524
+ ## Keyed State
525
+
526
+ Many stream transformations must reason across multiple events or timer firings. Windows, state machines, aggregates, and complex event processing all require state.
527
+
528
+ 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.
529
+
530
+ 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.
531
+
532
+ 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.
533
+
534
+ ### A counter for each key
535
+
536
+ Declare each collection once. Register it on the client. In a handler, get the current key's state from the event context:
537
+
538
+ ```ruby
539
+ COUNTER = Prosody.value("counter", ttl: 30 * 24 * 60 * 60)
540
+
541
+ class CountHandler < Prosody::EventHandler
542
+ def on_message(context, _message)
543
+ count = context.state(COUNTER)
544
+ count.set((count.get || 0) + 1)
545
+ end
546
+
547
+ def on_excise(context, _message)
548
+ context.state(COUNTER).clear
549
+ nil
550
+ end
551
+ def on_timer(_context, _timer); end
552
+ end
553
+
554
+ client = Prosody::Client.new(
555
+ group_id: "counters",
556
+ subscribed_topics: "events",
557
+ state_collections: [COUNTER]
558
+ )
559
+ ```
560
+
561
+ Each Kafka key now has an independent counter. A counter expires when that key has no update for 30 days.
562
+
563
+ ### Window activity into one notification
564
+
565
+ This example sends the first event for a user immediately. It collects later events for five minutes and then sends one summary.
566
+
567
+ The user ID is the Kafka key. Each user has an independent window.
568
+
569
+ ```ruby
570
+ WINDOW = Prosody.value("window", ttl: 24 * 60 * 60)
571
+ PENDING = Prosody.message_deque("pending", capacity: 100, ttl: 24 * 60 * 60)
572
+
573
+ class ActivityHandler < Prosody::EventHandler
574
+ def on_message(context, message)
575
+ window = context.state(WINDOW)
576
+ pending = context.state(PENDING)
577
+
578
+ if window.get
579
+ pending.push(message)
580
+ return
581
+ end
582
+
583
+ notify(message.key, [message])
584
+ window.set(true)
585
+ context.clear_and_schedule(Time.now + 5 * 60)
586
+ end
587
+
588
+ def on_timer(context, timer)
589
+ pending = context.state(PENDING)
590
+ batch = []
591
+ pending.each { |message| batch << message }
592
+
593
+ notify(timer.key, batch) unless batch.empty?
594
+ pending.clear
595
+ context.state(WINDOW).clear
596
+ end
597
+
598
+ def on_excise(context, _message)
599
+ context.state(PENDING).clear
600
+ context.state(WINDOW).clear
601
+ context.clear_scheduled
602
+ nil
603
+ end
604
+ end
605
+ ```
606
+
607
+ See the complete, Steep-checked example for signatures, client setup, and `notify`: [`examples/keyed_state_windowing.rb`](examples/keyed_state_windowing.rb) and [`examples/keyed_state_windowing.rbs`](examples/keyed_state_windowing.rbs).
608
+
609
+ Why this works:
610
+
611
+ - Register both definitions in `state_collections` before you subscribe. Keyed state uses Cassandra unless `mock: true`.
612
+ - Use `clear_and_schedule`, not `schedule`, so a retried event does not add another timer for the same key.
613
+ - `capacity: 100` and the one-day TTL bound the saved backlog. Overflow drops the oldest message because this example only appends.
614
+ - A `message_deque` requires the original Kafka messages during the window. Use `deque` when topic retention or compaction cannot provide them.
615
+ - Prosody runs one handler at a time for each key, so a user's message and timer handlers cannot overlap.
616
+ - A notification is outside the state transaction. A retry can send it again. Use a stable operation ID to reject duplicate notifications.
617
+
618
+ ### Collections and handles
619
+
620
+ A definition sets a collection's durable name, kind, and options. Register it once. Pass it to `context.state` in a handler.
621
+
622
+ 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.
623
+
624
+ State operations look synchronous. They yield the current fiber while Prosody performs the work.
625
+
626
+ | Collection | JSON payload | Kafka message | Main operations |
627
+ | --- | --- | --- | --- |
628
+ | Value | `Prosody.value` | `Prosody.message_value` | `get`, `set`, `clear` |
629
+ | Ordered string map | `Prosody.map` | `Prosody.message_map` | `get`, `get_many`, `key?`, `set`, `delete`, `each_pair`, `each_key`, `clear` |
630
+ | Deque | `Prosody.deque` | `Prosody.message_deque` | `push`, `unshift`, `pop`, `shift`, `get`, `length`, `each`, `clear` |
631
+
632
+ Map and deque scans return enumerators when called without a block. Map keys are strings.
633
+
634
+ `nil` means absence. Do not store this value. Use `clear` or `delete`.
635
+
636
+ ### When keyed-state changes become visible
637
+
638
+ 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 raises.
639
+
640
+ This transaction applies only to keyed state. Some workflows need state changes before the handler ends, so each collection also provides explicit controls:
641
+
642
+ - `read_uncommitted: 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.
643
+ - `commit` commits the collection's pending changes before the handler ends. A later handler failure does not remove them.
644
+ - `rollback` discards pending changes since the last `commit`. It cannot undo committed changes.
645
+
646
+ ### Published state
647
+
648
+ Some callers need only the current value for a key. They can accept a stale value or a race with a concurrent update.
649
+
650
+ 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.
651
+
652
+ Configure the subsystem name on each publisher. Enable publication on the collection definition. Register the definition on the Prosody client:
653
+
654
+ ```ruby
655
+ CURRENT_ORDER = Prosody.value("current-order", published: true)
656
+
657
+ owner = Prosody::Client.new(
658
+ group_id: "order-writer",
659
+ subsystem: "checkout",
660
+ state_collections: [CURRENT_ORDER]
661
+ )
662
+
663
+ # The handler uses the key from its current event.
664
+ current_order = context.state(CURRENT_ORDER)
665
+ current_order.set({"sku" => "book"})
666
+ ```
667
+
668
+ Read published state from a handler or other application code. The Prosody client does not need an active subscription.
669
+
670
+ Use the subsystem and the same definition to open a reader:
671
+
672
+ ```ruby
673
+ order_reader = client.state("checkout", CURRENT_ORDER)
674
+ current_order = order_reader.get("customer-123")
675
+ ```
676
+
677
+ 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.
678
+
679
+ Map and deque readers fetch data in chunks. They do not load the complete collection before iteration starts. Readers return an `Enumerator` without a block.
680
+
681
+ Use `reverse_each_pair`, `reverse_each_key`, `reverse_each_value`, or `reverse_each` for reverse traversal.
682
+
683
+ The default cache window is five seconds. Set `read_cache:` to select a different window. Set `read_cache: false` to bypass the cache.
684
+
685
+ To stop publication, deploy the definition with `published: false`. Keep the definition registered during that deployment. Keep the subsystem configured during that deployment.
686
+
532
687
  ## Timer Functionality
533
688
 
534
689
  Prosody supports timer-based delayed execution within message handlers. When a timer fires, your handler's `on_timer` method will be called:
@@ -556,6 +711,11 @@ class MyHandler < Prosody::EventHandler
556
711
  puts "Key: #{timer.key}"
557
712
  puts "Scheduled time: #{timer.time}"
558
713
  end
714
+
715
+ def on_excise(context, _message)
716
+ context.clear_scheduled
717
+ nil
718
+ end
559
719
  end
560
720
  ```
561
721
 
@@ -680,6 +840,9 @@ class MyHandler < Prosody::EventHandler
680
840
  })
681
841
  end
682
842
  end
843
+
844
+ def on_excise(_context, _message); end
845
+ def on_timer(_context, _timer); end
683
846
  end
684
847
  ```
685
848
 
@@ -734,22 +897,17 @@ Strategies for achieving idempotence:
734
897
  - Each message advances the state machine, allowing for idempotent processing and easy failure recovery.
735
898
  - Particularly useful for complex, distributed transactions across multiple services.
736
899
 
737
- ### Proper Shutdown
900
+ ### Application shutdown
901
+
902
+ 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.
738
903
 
739
- Always unsubscribe from topics before exiting your application:
904
+ 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`.
740
905
 
741
906
  ```ruby
742
- # Ensure proper shutdown
743
- client.unsubscribe
907
+ client.shutdown
744
908
  ```
745
909
 
746
- This ensures:
747
-
748
- 1. Completion and commitment of all in-flight work
749
- 2. Quick rebalancing, allowing other consumers to take over partitions
750
- 3. Proper release of resources
751
-
752
- Implement shutdown handling in your application using signal handlers:
910
+ Handle application shutdown with signal handlers:
753
911
 
754
912
  ```ruby
755
913
  require "prosody"
@@ -760,22 +918,22 @@ client = Prosody::Client.new(
760
918
  subscribed_topics: "my-topic"
761
919
  )
762
920
 
763
- # Set up a shutdown queue
921
+ # Create the shutdown queue.
764
922
  shutdown = Queue.new
765
923
 
766
- # Configure signal handlers to trigger shutdown
924
+ # Register the signal handlers.
767
925
  Signal.trap("INT") { shutdown.push(nil) }
768
926
  Signal.trap("TERM") { shutdown.push(nil) }
769
927
 
770
- # Subscribe to messages
928
+ # Subscribe with the application handler.
771
929
  client.subscribe(MyHandler.new)
772
930
 
773
- # Block until a signal is received
774
- shutdown.pop # This blocks until something is pushed to the queue by a signal handler
931
+ # Wait for a shutdown signal.
932
+ shutdown.pop
775
933
 
776
- # Clean shutdown
777
- puts "Shutting down gracefully..."
778
- client.unsubscribe
934
+ # Shut down the client.
935
+ puts "Client shutdown starts."
936
+ client.shutdown
779
937
  ```
780
938
 
781
939
  ### Error Handling
@@ -783,6 +941,8 @@ client.unsubscribe
783
941
  Prosody classifies errors as transient (temporary, can be retried) or permanent (won't be resolved by retrying). By
784
942
  default, all errors are considered transient.
785
943
 
944
+ The error classes and classification methods apply to `on_message`, `on_excise`, and `on_timer`.
945
+
786
946
  Use the `Prosody::EventHandler` error classification methods:
787
947
 
788
948
  ```ruby
@@ -799,6 +959,9 @@ class MyHandler < Prosody::EventHandler
799
959
  # JSON::ParserError will be treated as transient
800
960
  # All other exceptions will be treated as transient (default behavior)
801
961
  end
962
+
963
+ def on_excise(_context, _message); end
964
+ def on_timer(_context, _timer); end
802
965
  end
803
966
  ```
804
967
 
@@ -836,6 +999,9 @@ class MyHandler < Prosody::EventHandler
836
999
  release_resource(resource)
837
1000
  end
838
1001
  end
1002
+
1003
+ def on_excise(_context, _message); end
1004
+ def on_timer(_context, _timer); end
839
1005
  end
840
1006
  ```
841
1007
 
@@ -897,18 +1063,30 @@ Ensure you have thoroughly tested your changes before merging to `main`.
897
1063
 
898
1064
  ### Prosody::Client
899
1065
 
900
- - `new(**config)`: Initialize a new Prosody client with the given configuration.
901
- - `send_message(topic, key, payload)`: Send a message to a specified topic.
902
- - `consumer_state`: Get the current state of the consumer (`:unconfigured`, `:configured`, or `:running`).
1066
+ - `new(config)` or `new(**options)`: Create a client from a `Configuration`, hash, or keyword options.
1067
+ - `send_message(String topic, String key, Prosody::json_value payload)`: Send a JSON-serializable message.
1068
+ - `excise(String topic, String key)`: Send an excise record for a key.
1069
+ - `request(topic:, key:, payload:, subsystems:, timeout:)`: Return one outcome for each subsystem.
1070
+ - `request_excise(topic:, key:, subsystems:, timeout:)`: Return one excise outcome for each subsystem.
1071
+ - `consumer_state`: Get the client state (`:shut_down`, `:unconfigured`, `:configured`, or `:running`).
903
1072
  - `source_system`: Get the source system identifier configured for the client.
904
- - `subscribe(handler)`: Subscribe to messages using the provided handler.
905
- - `unsubscribe`: Unsubscribe from messages and shut down the consumer.
1073
+ - `state(subsystem, definition)`: Open a typed, read-only published value, map, or deque.
1074
+ - `subscribe(handler)`: Start event processing with the specified handler.
1075
+ - `unsubscribe`: Stop the consumer. You can subscribe again later.
1076
+ - `shutdown`: Stop all client services. Concurrent and repeated calls wait for the same operation.
906
1077
  - `assigned_partitions`: Get the number of partitions currently assigned to this consumer.
907
1078
  - `is_stalled?`: Check if the consumer has stalled partitions.
908
1079
 
1080
+ ### Prosody::AdminClient
1081
+
1082
+ - `new(bootstrap_servers)`: Create an admin client for the specified Kafka servers.
1083
+ - `create_topic(name, partitions, replication_factor)`: Create a Kafka topic.
1084
+ - `delete_topic(name)`: Delete a Kafka topic.
1085
+
909
1086
  ### Prosody::EventHandler
910
1087
 
911
- A base class for user-defined handlers:
1088
+ A base class for user-defined handlers. Its RBS payload parameter flows into
1089
+ `Message#payload`; a bare handler defaults to `Prosody::json_value`.
912
1090
 
913
1091
  ```ruby
914
1092
  class MyHandler < Prosody::EventHandler
@@ -923,26 +1101,61 @@ class MyHandler < Prosody::EventHandler
923
1101
  def on_timer(context, timer)
924
1102
  # Implement your timer handling logic here
925
1103
  end
1104
+
1105
+ def on_excise(_context, _message)
1106
+ # Implement your excise handling logic here
1107
+ end
926
1108
  end
927
1109
  ```
928
1110
 
1111
+ `on_message`, `on_excise`, and `on_timer` are the handler callbacks. The `permanent` and `transient` methods classify selected exceptions.
1112
+
929
1113
  ### Prosody::Message
930
1114
 
931
- Represents a Kafka message with the following attributes:
1115
+ `Prosody::Message[Payload]` represents a Kafka message. `Payload` defaults to
1116
+ `Prosody::json_value` (`nil`, booleans, numbers, strings, arrays, and
1117
+ string-keyed hashes, recursively). The parameter is static documentation and
1118
+ does not perform runtime validation.
1119
+
1120
+ For a type-safe handler, describe the JSON record and specialize the handler in
1121
+ your application's RBS:
1122
+
1123
+ ```rbs
1124
+ type order_event = { "order_id" => String, "total" => Integer }
1125
+ type response = { "accepted" => bool }
1126
+
1127
+ class OrderHandler < Prosody::EventHandler[order_event, response]
1128
+ def on_message: (Prosody::Context, Prosody::Message[order_event]) -> response
1129
+ def on_excise: (Prosody::Context, Prosody::ExciseMessage) -> response
1130
+ end
1131
+ ```
1132
+
1133
+ Ruby can then use `message.payload["order_id"]` as a `String` and
1134
+ `message.payload["total"]` as an `Integer`. See
1135
+ [`examples/keyed_state.rb`](examples/keyed_state.rb) and its companion
1136
+ [`examples/keyed_state.rbs`](examples/keyed_state.rbs) for payload typing that
1137
+ also flows through message-backed state.
1138
+
1139
+ Messages have the following attributes:
932
1140
 
933
1141
  - `topic` (String): The name of the topic.
934
1142
  - `partition` (Integer): The partition number.
935
1143
  - `offset` (Integer): The message offset within the partition.
936
1144
  - `timestamp` (Time): The timestamp when the message was created or sent.
937
1145
  - `key` (String): The message key.
938
- - `payload` (Hash/Array/String): The message payload as a JSON-deserializable value.
1146
+ - `payload` (`Payload`): The JSON-deserialized message payload.
1147
+
1148
+ ### Prosody::ExciseMessage
1149
+
1150
+ An `ExciseMessage` has `topic`, `partition`, `offset`, `timestamp`, and `key` attributes. It has no `payload` attribute.
939
1151
 
940
1152
  ### Prosody::Context
941
1153
 
942
- Represents the context of message processing:
1154
+ Represents the current event context:
943
1155
 
944
1156
  - `should_cancel?`: Check if cancellation has been requested (includes timeout and shutdown).
945
- - `on_cancel`: Blocks until cancellation is signaled.
1157
+ - `on_cancel`: Wait until cancellation occurs.
1158
+ - `state(definition)`: Bind a registered collection for the current attempt. An unregistered or mismatched definition raises `PermanentStateError`. See [Keyed State](#keyed-state-2).
946
1159
 
947
1160
  Timer scheduling methods:
948
1161
 
@@ -958,3 +1171,80 @@ Represents a timer that has fired, provided to the `on_timer` method:
958
1171
 
959
1172
  - `key` (String): The entity key identifying what this timer belongs to
960
1173
  - `time` (Time): The time when this timer was scheduled to fire
1174
+
1175
+ ### Requests
1176
+
1177
+ - `Prosody::Success[Value]`: Contains the response in `value`.
1178
+ - `Prosody::Failure`: Contains a response error in `error`.
1179
+ - `Prosody::HandlerError`, `Timeout`, `FormatMismatch`, and `MalformedResponse`: The possible response errors.
1180
+ - `Prosody::response_error`: The union of all response error types.
1181
+ - `Prosody::outcome[Value]`: A `Success[Value]` or `Failure`.
1182
+
1183
+ ### Keyed State
1184
+
1185
+ Definition constructors (each returns a frozen definition object used both in `Configuration#state_collections` and with `context.state`):
1186
+
1187
+ - `Prosody.value(name, ttl: nil, read_uncommitted: nil, published: nil, read_cache: nil)`
1188
+ - `Prosody.map(name, ttl: nil, keyset_limit: nil, read_uncommitted: nil, published: nil, read_cache: nil)`
1189
+ - `Prosody.deque(name, ttl: nil, capacity: nil, read_uncommitted: nil, published: nil, read_cache: nil)`
1190
+ - `Prosody.message_value(name, ttl: nil, read_uncommitted: nil)`
1191
+ - `Prosody.message_map(name, ttl: nil, keyset_limit: nil, read_uncommitted: nil)`
1192
+ - `Prosody.message_deque(name, ttl: nil, capacity: nil, read_uncommitted: nil)`
1193
+
1194
+ Each constructor returns a `StateDefinition`. It exposes `name`, `kind`, `payload`, all supplied options, and `to_state_config`.
1195
+
1196
+ Published readers take the user key as their first argument. `Prosody::PublishedValue` provides `get`.
1197
+
1198
+ `Prosody::PublishedMap` provides `get`, `get_many`, `key?`, `has_key?`, `include?`, and `member?`.
1199
+
1200
+ It provides `each` or `each_pair`, `each_key`, and `each_value`. The reverse methods are `reverse_each_pair`, `reverse_each_key`, and `reverse_each_value`.
1201
+
1202
+ `Prosody::PublishedDeque` provides `get`, `length` or `size`, `empty?`, `first`, `last`, `each`, and `reverse_each`.
1203
+
1204
+ Traversal methods return an `Enumerator` without a block.
1205
+
1206
+ `Prosody::ValueState`:
1207
+
1208
+ - `get` / `value`, `set(value)` / `value=`, `clear`, `commit`, and `rollback`
1209
+
1210
+ `Prosody::MapState` (keys are `String`):
1211
+
1212
+ - `get` / `[]`, `get_many`, `set` / `[]=`, `store`, `delete`, and `clear`
1213
+ - `key?`, `has_key?`, `include?`, `member?`, `dig`, `slice`, `values_at`, `fetch`, and `fetch_values`
1214
+ - `each` / `each_pair`, `each_key`, and `each_value`, including each reverse form
1215
+ - `commit` and `rollback`
1216
+
1217
+ `Prosody::DequeState`:
1218
+
1219
+ - `push`, `append`, `<<`, `unshift`, `prepend`, `pop`, and `shift`
1220
+ - `length` / `size`, `empty?`, `get`, `fetch`, `first`, `last`, and `clear`
1221
+ - `each` / `reverse_each`, `commit`, and `rollback`
1222
+
1223
+ Errors:
1224
+
1225
+ - `Prosody::TransientStateError < Prosody::TransientError`: Reports a keyed-state error that Prosody can retry.
1226
+ - `Prosody::PermanentStateError < Prosody::PermanentError`: Reports a keyed-state error that another attempt cannot resolve.
1227
+ - `Prosody::NullValueError < Prosody::TransientStateError`: raised when a `nil` is written; use `clear`/`delete` instead.
1228
+
1229
+ Handler error types:
1230
+
1231
+ - `Prosody::Error`: Base Prosody error.
1232
+ - `Prosody::EventHandlerError`: Base class for classified handler errors.
1233
+ - `Prosody::TransientError`: Marks an error as retriable.
1234
+ - `Prosody::PermanentError`: Marks an error as final.
1235
+
1236
+ ### Configuration
1237
+
1238
+ `Prosody::Configuration.new` accepts a hash or block. Its public properties match the settings in [Configuration](CONFIGURATION.md). `to_hash` returns a configuration hash.
1239
+
1240
+ ### Logging and telemetry
1241
+
1242
+ - `Prosody.logger`: Get the current logger or create the default logger.
1243
+ - `Prosody.logger=`: Replace the current logger. Assign `nil` to restore the default.
1244
+ - `Prosody.flush_telemetry`: Export pending telemetry.
1245
+ - `Prosody.shutdown_telemetry`: Export pending telemetry and stop its providers.
1246
+
1247
+ ### Sentry
1248
+
1249
+ - `Prosody::SentryIntegration.enabled?`: Test whether Sentry integration is active.
1250
+ - `Prosody::SentryIntegration.capture_exception(exception, context = {})`: Report an exception with optional context.