prosody 0.4.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 (51) 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 +2 -2
  6. data/CHANGELOG.md +15 -0
  7. data/CLAUDE.md +1 -0
  8. data/CONFIGURATION.md +167 -0
  9. data/Cargo.lock +660 -326
  10. data/Cargo.toml +2 -1
  11. data/README.md +290 -191
  12. data/examples/keyed_state.rb +15 -3
  13. data/examples/keyed_state_windowing.rb +9 -1
  14. data/ext/prosody/Cargo.toml +2 -1
  15. data/ext/prosody/src/admin.rs +1 -5
  16. data/ext/prosody/src/bridge/mod.rs +17 -32
  17. data/ext/prosody/src/client/config.rs +194 -89
  18. data/ext/prosody/src/client/mod.rs +167 -74
  19. data/ext/prosody/src/client/request.rs +132 -0
  20. data/ext/prosody/src/client/support.rs +122 -0
  21. data/ext/prosody/src/handler/context.rs +24 -20
  22. data/ext/prosody/src/handler/message.rs +50 -0
  23. data/ext/prosody/src/handler/mod.rs +112 -84
  24. data/ext/prosody/src/handler/state/mod.rs +488 -0
  25. data/ext/prosody/src/handler/state/registration.rs +104 -0
  26. data/ext/prosody/src/handler/state/scan.rs +218 -0
  27. data/ext/prosody/src/lib.rs +15 -3
  28. data/ext/prosody/src/published.rs +273 -0
  29. data/ext/prosody/src/scheduler/mod.rs +2 -2
  30. data/ext/prosody/src/scheduler/processor.rs +2 -2
  31. data/ext/prosody/src/scheduler/result.rs +7 -4
  32. data/ext/prosody/src/util.rs +86 -5
  33. data/lib/prosody/configuration.rb +49 -15
  34. data/lib/prosody/handler.rb +63 -10
  35. data/lib/prosody/native_stubs.rb +197 -31
  36. data/lib/prosody/request.rb +45 -0
  37. data/lib/prosody/state.rb +164 -41
  38. data/lib/prosody/version.rb +1 -1
  39. data/lib/prosody.rb +1 -0
  40. data/sig/configuration.rbs +51 -15
  41. data/sig/handler.rbs +12 -4
  42. data/sig/prosody.rbs +43 -2
  43. data/sig/request.rbs +66 -0
  44. data/sig/state.rbs +165 -47
  45. data/steep_expectations.yml +10 -0
  46. data/typecheck/payload_types.rb +14 -3
  47. data/typecheck/payload_types.rbs +4 -2
  48. data/typecheck_negative/payload_types.rb +4 -0
  49. data/typecheck_negative/payload_types.rbs +1 -0
  50. metadata +12 -2
  51. data/ext/prosody/src/handler/state.rs +0 -1035
data/README.md CHANGED
@@ -30,10 +30,10 @@ Or install directly:
30
30
  gem install prosody
31
31
  ```
32
32
 
33
- The gem ships RBS signatures for the public API. `Prosody::EventHandler[Payload]`
34
- carries an application payload type into `Prosody::Message[Payload]`, and keyed-
35
- state definitions carry their item types through `context.state`. A bare handler,
36
- message, definition, or state handle defaults to `Prosody::json_value`. See the
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
37
  [typed examples](examples/) for Ruby and companion RBS files checked by Steep.
38
38
 
39
39
  ## Quick Start
@@ -59,6 +59,12 @@ client = Prosody::Client.new(
59
59
 
60
60
  # Define a custom message handler
61
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
+
62
68
  def on_message(context, message)
63
69
  # Process the received message
64
70
  puts "Received message: #{message.payload.inspect}"
@@ -81,11 +87,22 @@ client.subscribe(MyHandler.new)
81
87
 
82
88
  # Send a message to a topic
83
89
  client.send_message("my-topic", "message-key", {"content" => "Hello, Kafka!"})
90
+ client.excise("my-topic", "obsolete-key")
84
91
 
85
92
  # Ensure proper shutdown when done
86
- client.unsubscribe
93
+ client.shutdown
87
94
  ```
88
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
+
89
106
  ## Architecture
90
107
 
91
108
  Prosody enables efficient, parallel processing of Kafka messages while maintaining order for messages with the same key:
@@ -173,134 +190,9 @@ timeout defaults to 80% of `stall_threshold`.
173
190
 
174
191
  ## Configuration
175
192
 
176
- Configure via constructor options or environment variables. Options fall back to environment variables when unset.
177
-
178
- ### Core
179
-
180
- | Option / Environment Variable | Description | Default |
181
- |-----------------------------------------|---------------------------------------------------|--------------|
182
- | `bootstrap_servers` / `PROSODY_BOOTSTRAP_SERVERS` | Kafka servers to connect to | - |
183
- | `group_id` / `PROSODY_GROUP_ID` | Consumer group name | - |
184
- | `subscribed_topics` / `PROSODY_SUBSCRIBED_TOPICS` | Topics to read from | - |
185
- | `allowed_events` / `PROSODY_ALLOWED_EVENTS` | Only process events matching these prefixes | (all) |
186
- | `source_system` / `PROSODY_SOURCE_SYSTEM` | Tag for outgoing messages (prevents reprocessing)| `<group_id>` |
187
- | `mock` / `PROSODY_MOCK` | Use in-memory Kafka for testing | false |
188
-
189
- ### Consumer
190
-
191
- | Option / Environment Variable | Description | Default |
192
- |-----------------------------------------|------------------------------------------------------|------------------------|
193
- | `max_concurrency` / `PROSODY_MAX_CONCURRENCY` | Max messages being processed simultaneously | 32 |
194
- | `max_uncommitted` / `PROSODY_MAX_UNCOMMITTED` | Max queued messages before pausing consumption | 64 |
195
- | `timeout` / `PROSODY_TIMEOUT` | Cancel handler if it runs longer than this | 80% of stall threshold |
196
- | `commit_interval` / `PROSODY_COMMIT_INTERVAL` | How often to save progress to Kafka | 1s |
197
- | `poll_interval` / `PROSODY_POLL_INTERVAL` | How often to fetch new messages from Kafka | 100ms |
198
- | `shutdown_timeout` / `PROSODY_SHUTDOWN_TIMEOUT` | Shutdown budget; handlers run freely until cancellation fires near the end of the timeout | 30s |
199
- | `stall_threshold` / `PROSODY_STALL_THRESHOLD` | Report unhealthy if no progress for this long | 5m |
200
- | `probe_port` / `PROSODY_PROBE_PORT` | HTTP port for health checks (nil to disable) | 8000 |
201
- | `failure_topic` / `PROSODY_FAILURE_TOPIC` | Send unprocessable messages here (dead letter queue) | - |
202
- | `idempotence_cache_size` / `PROSODY_IDEMPOTENCE_CACHE_SIZE` | Global shared cache capacity across all partitions for message deduplication. Consumer deduplication is mandatory and cannot be disabled, so this must be at least 1; setting it to 0 in the client configuration is rejected | 8192 |
203
- | `idempotence_version` / `PROSODY_IDEMPOTENCE_VERSION` | Version string for cache-busting dedup hashes | 1 |
204
- | `idempotence_ttl` / `PROSODY_IDEMPOTENCE_TTL` | TTL for dedup records in Cassandra | 7d (604800 seconds) |
205
- | `slab_size` / `PROSODY_SLAB_SIZE` | Timer storage granularity (rarely needs changing) | 1h |
206
- | `message_spans` / `PROSODY_MESSAGE_SPANS` | Span linking for message execution: `child` (child-of) or `follows_from` | `child` |
207
- | `timer_spans` / `PROSODY_TIMER_SPANS` | Span linking for timer execution: `child` (child-of) or `follows_from` | `follows_from` |
208
-
209
- ### Producer
210
-
211
- | Option / Environment Variable | Description | Default |
212
- |-----------------------------------------|---------------------------------|---------|
213
- | `send_timeout` / `PROSODY_SEND_TIMEOUT` | Give up sending after this long | 1s |
214
-
215
- ### Retry
216
-
217
- When a handler fails, retry with exponential backoff:
218
-
219
- | Option / Environment Variable | Description | Default |
220
- |-----------------------------------------|-----------------------------------|---------|
221
- | `max_retries` / `PROSODY_MAX_RETRIES` | Give up after this many attempts | 3 |
222
- | `retry_base` / `PROSODY_RETRY_BASE` | Wait this long before first retry | 20ms |
223
- | `max_retry_delay` / `PROSODY_RETRY_MAX_DELAY` | Never wait longer than this | 5m |
224
-
225
- ### Deferral (Pipeline Mode)
226
-
227
- | Option / Environment Variable | Description | Default |
228
- |-----------------------------------------|---------------------------------------------------|---------|
229
- | `defer_enabled` / `PROSODY_DEFER_ENABLED` | Enable deferral for new messages | true |
230
- | `defer_base` / `PROSODY_DEFER_BASE` | Wait this long before first deferred retry | 1s |
231
- | `defer_max_delay` / `PROSODY_DEFER_MAX_DELAY` | Never wait longer than this | 24h |
232
- | `defer_failure_threshold` / `PROSODY_DEFER_FAILURE_THRESHOLD` | Disable deferral when failure rate exceeds this | 0.9 |
233
- | `defer_failure_window` / `PROSODY_DEFER_FAILURE_WINDOW` | Measure failure rate over this time window | 5m |
234
- | `defer_cache_size` / `PROSODY_DEFER_CACHE_SIZE` | Track this many deferred keys in memory | 1024 |
235
- | `defer_store_cache_size` / `PROSODY_DEFER_STORE_CACHE_SIZE` | Maximum deferred store cache entries per Cassandra defer store | 8192 |
236
- | `defer_seek_timeout` / `PROSODY_DEFER_SEEK_TIMEOUT` | Timeout when loading deferred messages | 30s |
237
- | `defer_discard_threshold` / `PROSODY_DEFER_DISCARD_THRESHOLD` | Read optimization (rarely needs changing) | 100 |
238
-
239
- ### Monopolization Detection (Pipeline Mode)
240
-
241
- | Option / Environment Variable | Description | Default |
242
- |-----------------------------------------|-----------------------------------------|---------|
243
- | `monopolization_enabled` / `PROSODY_MONOPOLIZATION_ENABLED` | Enable hot key protection | true |
244
- | `monopolization_threshold` / `PROSODY_MONOPOLIZATION_THRESHOLD` | Max handler time as fraction of window | 0.9 |
245
- | `monopolization_window` / `PROSODY_MONOPOLIZATION_WINDOW` | Measurement window | 5m |
246
- | `monopolization_cache_size` / `PROSODY_MONOPOLIZATION_CACHE_SIZE` | Max distinct keys to track | 8192 |
247
-
248
- ### Fair Scheduling (All Modes)
249
-
250
- | Option / Environment Variable | Description | Default |
251
- |-----------------------------------------|------------------------------------------------------------------|---------|
252
- | `scheduler_failure_weight` / `PROSODY_SCHEDULER_FAILURE_WEIGHT` | Fraction of processing time reserved for retries | 0.3 |
253
- | `scheduler_max_wait` / `PROSODY_SCHEDULER_MAX_WAIT` | Messages waiting this long get maximum priority | 2m |
254
- | `scheduler_wait_weight` / `PROSODY_SCHEDULER_WAIT_WEIGHT` | Priority boost for waiting messages (higher = more aggressive) | 200.0 |
255
- | `scheduler_cache_size` / `PROSODY_SCHEDULER_CACHE_SIZE` | Max distinct keys to track | 8192 |
256
-
257
- ### Cassandra
258
-
259
- Persistent storage for timers and deferred retries (not needed if `mock: true`):
193
+ For the complete configuration reference, see [CONFIGURATION.md](CONFIGURATION.md).
260
194
 
261
- | Option / Environment Variable | Description | Default |
262
- |-----------------------------------------|------------------------------------|---------|
263
- | `cassandra_nodes` / `PROSODY_CASSANDRA_NODES` | Servers to connect to (host:port) | - |
264
- | `cassandra_keyspace` / `PROSODY_CASSANDRA_KEYSPACE` | Keyspace name | prosody |
265
- | `cassandra_user` / `PROSODY_CASSANDRA_USER` | Username | - |
266
- | `cassandra_password` / `PROSODY_CASSANDRA_PASSWORD` | Password | - |
267
- | `cassandra_datacenter` / `PROSODY_CASSANDRA_DATACENTER` | Prefer this datacenter for queries | - |
268
- | `cassandra_rack` / `PROSODY_CASSANDRA_RACK` | Prefer this rack for queries | - |
269
- | `cassandra_retention` / `PROSODY_CASSANDRA_RETENTION` | Delete data older than this | 1y |
270
-
271
- ### Keyed State
272
-
273
- Register keyed-state collections before you subscribe. Persistence is backed by Cassandra and is not needed when `mock: true`. See the [Keyed State](#keyed-state-1) feature section for handler usage; the client-level knobs and per-collection fields are below. Where an option and an environment variable are paired, an explicitly set option wins; otherwise the environment variable applies, then the default.
274
-
275
- | Option / Environment Variable | Description | Default |
276
- |-------------------------------|-------------|---------|
277
- | `state_collections` / - | Keyed-state collections to register before subscribe (array of definitions or config hashes; duplicate names rejected) | (none) |
278
- | `state_cache_dir` / `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 |
279
- | `state_cache_size_bytes` / `PROSODY_STATE_CACHE_SIZE_BYTES` | Capacity of the in-memory keyed-state cache, in bytes; must be greater than 0. One cache is shared by all partition keyspaces | engine default |
280
- | `state_recovery_delay` / `PROSODY_STATE_RECOVERY_DELAY` | Whole-second delay between staging a provisional cell and the recovery sweep; every collection TTL must strictly exceed it | 30s |
281
-
282
- Prefer the definition constructors (`Prosody.value` / `.map` / `.deque` and their `message_*` variants, documented below): they serialize into `state_collections` so you declare each collection once and reuse the same object with `context.state`. Each entry has these fields:
283
-
284
- | Field | Description | Default |
285
- |-------|-------------|---------|
286
- | `name` | Collection name; non-empty and unique within the client | (required) |
287
- | `kind` | `"value"`, `"map"`, or `"deque"` | (required) |
288
- | `payload` | `"json"` (JSON values) or `"message"` (the full Kafka message the handler received) | (required) |
289
- | `ttl_seconds` | Per-write TTL in whole seconds (at least 1; must exceed the recovery delay) | (none) |
290
- | `read_uncommitted` | Opt out of transactional staging | false |
291
- | `keyset_limit` | Map-only; ordered-scan bound in `0..=4096` (`0` disables ordered-scan tracking) | 128 |
292
- | `capacity` | Deque-only window bound (at least 1); keeps at most N slots, enforced lazily on push. Runtime-only and mutable across deploys — not persisted | unbounded |
293
-
294
- Constructors set these via keyword arguments (`ttl:`, `keyset_limit:`, `capacity:`, `read_uncommitted:`).
295
-
296
- ### Telemetry Emitter
297
-
298
- Prosody can emit internal processing events (message lifecycle, timer events) to a Kafka topic for observability:
299
-
300
- | Option / Environment Variable | Description | Default |
301
- |-----------------------------------------|------------------------------------------------|----------------------------|
302
- | `telemetry_topic` / `PROSODY_TELEMETRY_TOPIC` | Kafka topic to produce telemetry events to | `prosody.telemetry-events` |
303
- | `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.
304
196
 
305
197
  ## Logging
306
198
 
@@ -393,6 +285,73 @@ if client.is_stalled?
393
285
  end
394
286
  ```
395
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
+
396
355
  ## Advanced Usage
397
356
 
398
357
  ### Pipeline Mode
@@ -564,15 +523,17 @@ Note that the in-memory cache is best-effort. Duplicates can still occur across
564
523
 
565
524
  ## Keyed State
566
525
 
567
- Keyed state gives every Kafka key its own durable working memory. Prosody automatically uses the current message or timer key, so a handler can relate the current event to earlier events for that key. State survives restarts and rebalances. By default, changes become visible only when the event succeeds.
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.
568
529
 
569
- Use keyed state for time-aware stream processing: counters, deduplication, rolling aggregates, pending work, and per-key workflows. Keep your relational database as the source of truth for business data and for work that needs joins or ad hoc queries. Reconstructing stream state with repeated database queries can be slow and expensive; keyed state is built for that job.
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.
570
531
 
571
- Most collections should have a TTL. Set it comfortably beyond the longest timer or workflow that uses the state; Prosody validates the minimum supported TTL. Omit it only when keeping inactive keys forever is intentional.
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.
572
533
 
573
534
  ### A counter for each key
574
535
 
575
- Declare each collection once, register it on the client, and ask the event context for the current key's state:
536
+ Declare each collection once. Register it on the client. In a handler, get the current key's state from the event context:
576
537
 
577
538
  ```ruby
578
539
  COUNTER = Prosody.value("counter", ttl: 30 * 24 * 60 * 60)
@@ -582,6 +543,12 @@ class CountHandler < Prosody::EventHandler
582
543
  count = context.state(COUNTER)
583
544
  count.set((count.get || 0) + 1)
584
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
585
552
  end
586
553
 
587
554
  client = Prosody::Client.new(
@@ -591,11 +558,13 @@ client = Prosody::Client.new(
591
558
  )
592
559
  ```
593
560
 
594
- Here, counters expire after 30 days without an update.
561
+ Each Kafka key now has an independent counter. A counter expires when that key has no update for 30 days.
595
562
 
596
563
  ### Window activity into one notification
597
564
 
598
- This example turns a burst of activity into two useful notifications. It sends the first event immediately, collects later events for five minutes, then sends one summary. Because the user ID is the Kafka key, every user gets an independent window.
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.
599
568
 
600
569
  ```ruby
601
570
  WINDOW = Prosody.value("window", ttl: 24 * 60 * 60)
@@ -625,6 +594,13 @@ class ActivityHandler < Prosody::EventHandler
625
594
  pending.clear
626
595
  context.state(WINDOW).clear
627
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
628
604
  end
629
605
  ```
630
606
 
@@ -632,18 +608,20 @@ See the complete, Steep-checked example for signatures, client setup, and `notif
632
608
 
633
609
  Why this works:
634
610
 
635
- - Register both definitions in `state_collections` before subscribing. Keyed state uses Cassandra unless `mock: true`.
611
+ - Register both definitions in `state_collections` before you subscribe. Keyed state uses Cassandra unless `mock: true`.
636
612
  - Use `clear_and_schedule`, not `schedule`, so a retried event does not add another timer for the same key.
637
- - `capacity: 100` and the one-day TTL prevent an inactive or unusually busy key from retaining an unlimited backlog. Since this example only appends, overflow drops the oldest saved message.
638
- - A `message_deque` requires the original Kafka messages to remain available for the whole window. Use a plain `deque` of payloads if topic retention or compaction cannot guarantee that.
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.
639
615
  - Prosody runs one handler at a time for each key, so a user's message and timer handlers cannot overlap.
640
- - Sending a notification is outside Prosody's state transaction and may happen again after a retry. Give notifications a stable idempotency key, or send them through an outbox, when duplicates matter.
616
+ - A notification is outside the state transaction. A retry can send it again. Use a stable operation ID to reject duplicate notifications.
641
617
 
642
618
  ### Collections and handles
643
619
 
644
- A definition gives a collection a stable name, kind, and options. Register it once on the client, then pass the same definition to `context.state` to access the current key. Do not reuse a persisted name for a different collection kind or payload type.
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.
645
623
 
646
- Create handles inside the handler and do not retain them or their iterators afterward. State operations look synchronous but yield the current fiber while Prosody performs the work.
624
+ State operations look synchronous. They yield the current fiber while Prosody performs the work.
647
625
 
648
626
  | Collection | JSON payload | Kafka message | Main operations |
649
627
  | --- | --- | --- | --- |
@@ -651,17 +629,60 @@ Create handles inside the handler and do not retain them or their iterators afte
651
629
  | Ordered string map | `Prosody.map` | `Prosody.message_map` | `get`, `get_many`, `key?`, `set`, `delete`, `each_pair`, `each_key`, `clear` |
652
630
  | Deque | `Prosody.deque` | `Prosody.message_deque` | `push`, `unshift`, `pop`, `shift`, `get`, `length`, `each`, `clear` |
653
631
 
654
- Map and deque scans return enumerators when called without a block. Map keys are strings. `nil` means absence and cannot be stored—use `clear` or `delete` instead.
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
655
637
 
656
- ### When changes become visible
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.
657
639
 
658
- Reads inside a handler see its earlier writes. The default behavior is the safest choice for most handlers: Prosody buffers those changes and publishes them together when the event succeeds. If the handler raises, none of its pending changes become visible.
640
+ This transaction applies only to keyed state. Some workflows need state changes before the handler ends, so each collection also provides explicit controls:
659
641
 
660
- Each collection also offers explicit controls for workflows that need different behavior:
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.
661
645
 
662
- - `read_uncommitted: true` writes that collection's changes after the handler succeeds but before the event is recorded as complete. A crash in between can leave the changes visible even though the event is retried. Use it only for idempotent changes, where processing the same event again produces the same stored result.
663
- - `commit` immediately publishes this collection's pending changes. They remain visible even if the handler later raises and the event is retried.
664
- - `rollback` discards this collection's pending changes since its last `commit`. It cannot undo changes that were already committed.
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.
665
686
 
666
687
  ## Timer Functionality
667
688
 
@@ -690,6 +711,11 @@ class MyHandler < Prosody::EventHandler
690
711
  puts "Key: #{timer.key}"
691
712
  puts "Scheduled time: #{timer.time}"
692
713
  end
714
+
715
+ def on_excise(context, _message)
716
+ context.clear_scheduled
717
+ nil
718
+ end
693
719
  end
694
720
  ```
695
721
 
@@ -814,6 +840,9 @@ class MyHandler < Prosody::EventHandler
814
840
  })
815
841
  end
816
842
  end
843
+
844
+ def on_excise(_context, _message); end
845
+ def on_timer(_context, _timer); end
817
846
  end
818
847
  ```
819
848
 
@@ -868,22 +897,17 @@ Strategies for achieving idempotence:
868
897
  - Each message advances the state machine, allowing for idempotent processing and easy failure recovery.
869
898
  - Particularly useful for complex, distributed transactions across multiple services.
870
899
 
871
- ### 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.
872
903
 
873
- 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`.
874
905
 
875
906
  ```ruby
876
- # Ensure proper shutdown
877
- client.unsubscribe
907
+ client.shutdown
878
908
  ```
879
909
 
880
- This ensures:
881
-
882
- 1. Completion and commitment of all in-flight work
883
- 2. Quick rebalancing, allowing other consumers to take over partitions
884
- 3. Proper release of resources
885
-
886
- Implement shutdown handling in your application using signal handlers:
910
+ Handle application shutdown with signal handlers:
887
911
 
888
912
  ```ruby
889
913
  require "prosody"
@@ -894,22 +918,22 @@ client = Prosody::Client.new(
894
918
  subscribed_topics: "my-topic"
895
919
  )
896
920
 
897
- # Set up a shutdown queue
921
+ # Create the shutdown queue.
898
922
  shutdown = Queue.new
899
923
 
900
- # Configure signal handlers to trigger shutdown
924
+ # Register the signal handlers.
901
925
  Signal.trap("INT") { shutdown.push(nil) }
902
926
  Signal.trap("TERM") { shutdown.push(nil) }
903
927
 
904
- # Subscribe to messages
928
+ # Subscribe with the application handler.
905
929
  client.subscribe(MyHandler.new)
906
930
 
907
- # Block until a signal is received
908
- shutdown.pop # This blocks until something is pushed to the queue by a signal handler
931
+ # Wait for a shutdown signal.
932
+ shutdown.pop
909
933
 
910
- # Clean shutdown
911
- puts "Shutting down gracefully..."
912
- client.unsubscribe
934
+ # Shut down the client.
935
+ puts "Client shutdown starts."
936
+ client.shutdown
913
937
  ```
914
938
 
915
939
  ### Error Handling
@@ -917,6 +941,8 @@ client.unsubscribe
917
941
  Prosody classifies errors as transient (temporary, can be retried) or permanent (won't be resolved by retrying). By
918
942
  default, all errors are considered transient.
919
943
 
944
+ The error classes and classification methods apply to `on_message`, `on_excise`, and `on_timer`.
945
+
920
946
  Use the `Prosody::EventHandler` error classification methods:
921
947
 
922
948
  ```ruby
@@ -933,6 +959,9 @@ class MyHandler < Prosody::EventHandler
933
959
  # JSON::ParserError will be treated as transient
934
960
  # All other exceptions will be treated as transient (default behavior)
935
961
  end
962
+
963
+ def on_excise(_context, _message); end
964
+ def on_timer(_context, _timer); end
936
965
  end
937
966
  ```
938
967
 
@@ -970,6 +999,9 @@ class MyHandler < Prosody::EventHandler
970
999
  release_resource(resource)
971
1000
  end
972
1001
  end
1002
+
1003
+ def on_excise(_context, _message); end
1004
+ def on_timer(_context, _timer); end
973
1005
  end
974
1006
  ```
975
1007
 
@@ -1031,15 +1063,26 @@ Ensure you have thoroughly tested your changes before merging to `main`.
1031
1063
 
1032
1064
  ### Prosody::Client
1033
1065
 
1034
- - `new(**config)`: Initialize a new Prosody client with the given configuration.
1066
+ - `new(config)` or `new(**options)`: Create a client from a `Configuration`, hash, or keyword options.
1035
1067
  - `send_message(String topic, String key, Prosody::json_value payload)`: Send a JSON-serializable message.
1036
- - `consumer_state`: Get the current state of the consumer (`:unconfigured`, `:configured`, or `:running`).
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`).
1037
1072
  - `source_system`: Get the source system identifier configured for the client.
1038
- - `subscribe: [Payload] (Prosody::EventHandler[Payload]) -> void`: Subscribe while preserving the handler's payload specialization.
1039
- - `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.
1040
1077
  - `assigned_partitions`: Get the number of partitions currently assigned to this consumer.
1041
1078
  - `is_stalled?`: Check if the consumer has stalled partitions.
1042
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
+
1043
1086
  ### Prosody::EventHandler
1044
1087
 
1045
1088
  A base class for user-defined handlers. Its RBS payload parameter flows into
@@ -1058,9 +1101,15 @@ class MyHandler < Prosody::EventHandler
1058
1101
  def on_timer(context, timer)
1059
1102
  # Implement your timer handling logic here
1060
1103
  end
1104
+
1105
+ def on_excise(_context, _message)
1106
+ # Implement your excise handling logic here
1107
+ end
1061
1108
  end
1062
1109
  ```
1063
1110
 
1111
+ `on_message`, `on_excise`, and `on_timer` are the handler callbacks. The `permanent` and `transient` methods classify selected exceptions.
1112
+
1064
1113
  ### Prosody::Message
1065
1114
 
1066
1115
  `Prosody::Message[Payload]` represents a Kafka message. `Payload` defaults to
@@ -1073,9 +1122,11 @@ your application's RBS:
1073
1122
 
1074
1123
  ```rbs
1075
1124
  type order_event = { "order_id" => String, "total" => Integer }
1125
+ type response = { "accepted" => bool }
1076
1126
 
1077
- class OrderHandler < Prosody::EventHandler[order_event]
1078
- def on_message: (Prosody::Context, Prosody::Message[order_event]) -> void
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
1079
1130
  end
1080
1131
  ```
1081
1132
 
@@ -1094,13 +1145,17 @@ Messages have the following attributes:
1094
1145
  - `key` (String): The message key.
1095
1146
  - `payload` (`Payload`): The JSON-deserialized message payload.
1096
1147
 
1148
+ ### Prosody::ExciseMessage
1149
+
1150
+ An `ExciseMessage` has `topic`, `partition`, `offset`, `timestamp`, and `key` attributes. It has no `payload` attribute.
1151
+
1097
1152
  ### Prosody::Context
1098
1153
 
1099
- Represents the context of message processing:
1154
+ Represents the current event context:
1100
1155
 
1101
1156
  - `should_cancel?`: Check if cancellation has been requested (includes timeout and shutdown).
1102
- - `on_cancel`: Blocks until cancellation is signaled.
1103
- - `state(definition)`: Binds a registered collection for the current event attempt, returning a typed handle (`ValueState`, `MapState`, or `DequeState`). Raises `PermanentStateError` when the name was never registered, or when the definition's `kind`/`payload` disagrees with the collection's durably-registered schema. See the [Keyed State](#keyed-state-2) API reference below.
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).
1104
1159
 
1105
1160
  Timer scheduling methods:
1106
1161
 
@@ -1117,35 +1172,79 @@ Represents a timer that has fired, provided to the `on_timer` method:
1117
1172
  - `key` (String): The entity key identifying what this timer belongs to
1118
1173
  - `time` (Time): The time when this timer was scheduled to fire
1119
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
+
1120
1183
  ### Keyed State
1121
1184
 
1122
1185
  Definition constructors (each returns a frozen definition object used both in `Configuration#state_collections` and with `context.state`):
1123
1186
 
1124
- - `Prosody.value(name, ttl: nil, read_uncommitted: nil)`
1125
- - `Prosody.map(name, ttl: nil, keyset_limit: nil, read_uncommitted: nil)`
1126
- - `Prosody.deque(name, ttl: nil, read_uncommitted: nil)`
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)`
1127
1190
  - `Prosody.message_value(name, ttl: nil, read_uncommitted: nil)`
1128
1191
  - `Prosody.message_map(name, ttl: nil, keyset_limit: nil, read_uncommitted: nil)`
1129
- - `Prosody.message_deque(name, ttl: 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.
1130
1205
 
1131
1206
  `Prosody::ValueState`:
1132
1207
 
1133
- - `get`, `set(value)`, `clear`, `commit`, `rollback`
1208
+ - `get` / `value`, `set(value)` / `value=`, `clear`, `commit`, and `rollback`
1134
1209
 
1135
1210
  `Prosody::MapState` (keys are `String`):
1136
1211
 
1137
- - `get(key)`, `get_many(keys)`, `set(key, value)`, `delete(key)` (returns `nil`), `clear`
1138
- - `each_pair` / `reverse_each_pair` (block or `Enumerator`), `commit`, `rollback`
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`
1139
1216
 
1140
1217
  `Prosody::DequeState`:
1141
1218
 
1142
- - `push(value)`, `unshift(value)`, `pop`, `shift`, `length` (aliased `size`), `empty?`, `get(index)`, `clear`
1143
- - `each` / `reverse_each` (block or `Enumerator`), `commit`, `rollback`
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`
1144
1222
 
1145
1223
  Errors:
1146
1224
 
1147
- - `Prosody::TransientStateError < Prosody::TransientError`: the default — a temporary store read/write failure, or any caller mistake (a `nil`/unrepresentable write, item-shape mismatch, out-of-range index, invalid scan direction), rejected transient so it retries rather than discarding the message.
1148
- - `Prosody::PermanentStateError < Prosody::PermanentError`: reserved for failures a retry cannot resolve in-process (unregistered/identity-mismatched collection, duplicate registration, bad TTL), or one a handler raises explicitly.
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.
1149
1227
  - `Prosody::NullValueError < Prosody::TransientStateError`: raised when a `nil` is written; use `clear`/`delete` instead.
1150
1228
 
1151
- State errors are never Terminal (core folds Terminal into Transient).
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.