prosody 0.2.1 → 0.4.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.
@@ -0,0 +1,1035 @@
1
+ //! Native layer for keyed state.
2
+ //!
3
+ //! Wraps the boxed erased handles from [`prosody::consumer::event_context`] as
4
+ //! Magnus classes. Collections are addressed by name; JSON payloads cross as
5
+ //! `serde_json::Value` via `serde_magnus` (exactly like [`Message`] payloads),
6
+ //! and Kafka-message items cross as the same [`Message`] object handlers
7
+ //! already receive.
8
+ //!
9
+ //! Every operation flows through [`Bridge::wait_for`]: the calling fiber yields
10
+ //! (via the fiber-scheduler-integrated `Queue#pop`) while tokio drives the
11
+ //! erased future, so the call looks blocking but never blocks the thread. The
12
+ //! extracted Ruby OpenTelemetry carrier is activated
13
+ //! (`FutureExt::with_context`) while core polls, so core's one semantic
14
+ //! collection span joins the event trace with no binding span.
15
+ //! Ruby↔`serde_json::Value` conversion runs on the Ruby thread *after*
16
+ //! `wait_for` returns, mirroring [`Message::payload`].
17
+ //!
18
+ //! Errors are STRUCTURAL: [`ErasedStateError::category`] selects the Ruby class
19
+ //! directly, and because [`crate::PermanentStateError`]/`TransientStateError`
20
+ //! subclass the existing `PermanentError`/`TransientError`, a rethrown state
21
+ //! error classifies correctly with no result-bridge change. Every caller
22
+ //! mistake the glue detects (a null write, a wrong item shape, an invalid
23
+ //! direction token, an unrepresentable value) rejects TRANSIENT so the message
24
+ //! retries and stays visible rather than being discarded.
25
+ //!
26
+ //! # Cancellation honesty
27
+ //!
28
+ //! `Async::Stop` may unwind the waiting fiber while the dispatched tokio op
29
+ //! completes detached — its effect landed before the boundary or is
30
+ //! epoch-fenced by core, and the result channel is simply dropped. For a
31
+ //! [`StateScan`] pull, the orphaned chunk is lost, but on cancellation the
32
+ //! whole attempt aborts and the scan is closed via the Ruby `ensure`, so the
33
+ //! dropped chunk is moot. Adding an in-flight replay slot would be new
34
+ //! architecture and would reimplement cancellation safety the contract assigns
35
+ //! to core.
36
+
37
+ use crate::bridge::{Bridge, QUEUE_CLASS};
38
+ use crate::handler::message::Message;
39
+ use crate::tracing_util::extract_opentelemetry_context;
40
+ use crate::util::ThreadSafeValue;
41
+ use crate::{ROOT_MOD, id};
42
+ use magnus::value::{Lazy, ReprValue};
43
+ use magnus::{Error, ExceptionClass, IntoValue, Module, Ruby, TryConvert, Value, method};
44
+ use opentelemetry::propagation::TextMapCompositePropagator;
45
+ use opentelemetry::trace::FutureExt;
46
+ use prosody::consumer::event_context::{
47
+ DynDequeState, DynMapState, DynValueState, ErasedCategory, ErasedStateError, StateCursor,
48
+ };
49
+ use prosody::consumer::message::ConsumerMessage;
50
+ use prosody::state::Direction;
51
+ use serde_json::Value as JsonValue;
52
+ use serde_magnus::{deserialize, serialize};
53
+ use std::cell::RefCell;
54
+ use std::collections::VecDeque;
55
+ use std::num::NonZeroUsize;
56
+ use std::sync::Arc;
57
+ use tracing::Span;
58
+
59
+ /// Lazily resolved `Prosody::PermanentStateError` class (defined in Ruby).
60
+ #[allow(
61
+ clippy::expect_used,
62
+ reason = "mirrors bridge.rs QUEUE_CLASS Lazy pattern"
63
+ )]
64
+ static PERMANENT_STATE_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
65
+ ruby.get_inner(&ROOT_MOD)
66
+ .const_get("PermanentStateError")
67
+ .expect("Prosody::PermanentStateError")
68
+ });
69
+
70
+ /// Lazily resolved `Prosody::TransientStateError` class (defined in Ruby).
71
+ #[allow(
72
+ clippy::expect_used,
73
+ reason = "mirrors bridge.rs QUEUE_CLASS Lazy pattern"
74
+ )]
75
+ static TRANSIENT_STATE_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
76
+ ruby.get_inner(&ROOT_MOD)
77
+ .const_get("TransientStateError")
78
+ .expect("Prosody::TransientStateError")
79
+ });
80
+
81
+ /// Lazily resolved `Prosody::NullValueError` class (defined in Ruby).
82
+ #[allow(
83
+ clippy::expect_used,
84
+ reason = "mirrors bridge.rs QUEUE_CLASS Lazy pattern"
85
+ )]
86
+ static NULL_VALUE_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
87
+ ruby.get_inner(&ROOT_MOD)
88
+ .const_get("NullValueError")
89
+ .expect("Prosody::NullValueError")
90
+ });
91
+
92
+ /// Maximum number of immediately-ready scan items transported in one chunk.
93
+ /// Core owns ready-draining, error ordering, and pull serialization; this
94
+ /// binding owns only the transport cap and conversion.
95
+ #[allow(clippy::unwrap_used, reason = "256 is a nonzero literal; mirrors core")]
96
+ const SCAN_READY_CHUNK_SIZE: NonZeroUsize = NonZeroUsize::new(256).unwrap();
97
+
98
+ /// Maps an erased state error to the matching Ruby state-error class.
99
+ ///
100
+ /// The category is data, so this never parses the human message. Because the
101
+ /// Ruby classes subclass the existing error hierarchy, the result bridge's
102
+ /// `#permanent?` path reclassifies a rethrown error with no change.
103
+ pub(crate) fn state_error(ruby: &Ruby, error: &ErasedStateError) -> Error {
104
+ let class = match error.category() {
105
+ ErasedCategory::Permanent => ruby.get_inner(&PERMANENT_STATE_ERROR),
106
+ ErasedCategory::Transient => ruby.get_inner(&TRANSIENT_STATE_ERROR),
107
+ };
108
+ Error::new(class, error.message().to_owned())
109
+ }
110
+
111
+ /// Builds a transient state error for a caller-caused condition the glue
112
+ /// detects (a wrong item shape, an invalid direction token, an unrepresentable
113
+ /// value).
114
+ fn transient_state_error(ruby: &Ruby, message: String) -> Error {
115
+ Error::new(ruby.get_inner(&TRANSIENT_STATE_ERROR), message)
116
+ }
117
+
118
+ /// Builds a null-value error for a rejected JSON-null write.
119
+ fn null_value_error(ruby: &Ruby, message: String) -> Error {
120
+ Error::new(ruby.get_inner(&NULL_VALUE_ERROR), message)
121
+ }
122
+
123
+ /// Converts an optional JSON item into a Ruby value or `nil`.
124
+ fn json_or_nil(ruby: &Ruby, item: Option<JsonValue>) -> Result<Value, Error> {
125
+ match item {
126
+ Some(value) => serialize(ruby, &value),
127
+ None => Ok(ruby.qnil().as_value()),
128
+ }
129
+ }
130
+
131
+ /// Converts an optional message item into a `Prosody::Message` or `nil`.
132
+ #[allow(
133
+ clippy::unnecessary_wraps,
134
+ reason = "parity with json_or_nil for uniform call sites"
135
+ )]
136
+ fn message_or_nil(ruby: &Ruby, item: Option<ConsumerMessage<JsonValue>>) -> Result<Value, Error> {
137
+ match item {
138
+ Some(message) => Ok(Message::from(message).into_value_with(ruby)),
139
+ None => Ok(ruby.qnil().as_value()),
140
+ }
141
+ }
142
+
143
+ /// Converts a Ruby argument into a storable JSON item.
144
+ ///
145
+ /// A value with no JSON representation is a caller mistake and rejects
146
+ /// transient. JSON `null` is rejected with a [`crate::NullValueError`] naming
147
+ /// the deletion verb via `null_advice`.
148
+ fn json_write_item(ruby: &Ruby, value: Value, null_advice: &str) -> Result<JsonValue, Error> {
149
+ let value: JsonValue = deserialize(ruby, value).map_err(|error| {
150
+ transient_state_error(ruby, format!("value is not representable as JSON: {error}"))
151
+ })?;
152
+ if value.is_null() {
153
+ return Err(null_value_error(
154
+ ruby,
155
+ format!("JSON null is not a storable value{null_advice}"),
156
+ ));
157
+ }
158
+ Ok(value)
159
+ }
160
+
161
+ /// Converts a Ruby argument into a storable message item.
162
+ ///
163
+ /// A non-message argument is a caller mistake (a JSON payload cannot be stored
164
+ /// in a message collection) and rejects transient. The wrapped
165
+ /// [`ConsumerMessage`] is cloned; see [`Message::consumer_message`].
166
+ fn message_write_item(
167
+ ruby: &Ruby,
168
+ value: Value,
169
+ shape_advice: &str,
170
+ ) -> Result<ConsumerMessage<JsonValue>, Error> {
171
+ let message = <&Message>::try_convert(value).map_err(|_| {
172
+ transient_state_error(ruby, format!("expected a Prosody::Message{shape_advice}"))
173
+ })?;
174
+ Ok(message.consumer_message())
175
+ }
176
+
177
+ /// Parses a scan-direction token into the core [`Direction`].
178
+ ///
179
+ /// An invalid token is a caller mistake and rejects transient.
180
+ fn parse_direction(ruby: &Ruby, direction: &str) -> Result<Direction, Error> {
181
+ match direction {
182
+ "forward" => Ok(Direction::Forward),
183
+ "backward" => Ok(Direction::Backward),
184
+ other => Err(transient_state_error(
185
+ ruby,
186
+ format!("direction: expected \"forward\" or \"backward\", got {other:?}"),
187
+ )),
188
+ }
189
+ }
190
+
191
+ /// Drives an erased async op that returns `Result<_, ErasedStateError>` through
192
+ /// [`Bridge::wait_for`] with the extracted carrier active, yielding the op's
193
+ /// `Ok` value (state error mapped to the matching Ruby class).
194
+ macro_rules! run_op {
195
+ ($ruby:expr, $this:expr, $handle:expr, $call:ident ( $($arg:expr),* )) => {{
196
+ let handle = Arc::clone($handle);
197
+ let context = extract_opentelemetry_context($ruby, &$this.propagator)?;
198
+ $this
199
+ .bridge
200
+ .wait_for(
201
+ $ruby,
202
+ async move { handle.$call($($arg),*).with_context(context).await },
203
+ Span::current(),
204
+ )?
205
+ .map_err(|error| state_error($ruby, &error))
206
+ }};
207
+ }
208
+
209
+ /// Drives an infallible erased async op (returning `()`) through
210
+ /// [`Bridge::wait_for`] with the extracted carrier active.
211
+ macro_rules! run_infallible {
212
+ ($ruby:expr, $this:expr, $handle:expr, $call:ident ()) => {{
213
+ let handle = Arc::clone($handle);
214
+ let context = extract_opentelemetry_context($ruby, &$this.propagator)?;
215
+ $this.bridge.wait_for(
216
+ $ruby,
217
+ async move { handle.$call().with_context(context).await },
218
+ Span::current(),
219
+ )?
220
+ }};
221
+ }
222
+
223
+ /// The two payload flavours a value handle wraps.
224
+ pub(crate) enum ValueStateVariant {
225
+ /// A JSON value collection.
226
+ Json(Arc<dyn DynValueState<JsonValue>>),
227
+ /// A Kafka-message collection.
228
+ Message(Arc<dyn DynValueState<ConsumerMessage<JsonValue>>>),
229
+ }
230
+
231
+ /// The two payload flavours a map handle wraps.
232
+ pub(crate) enum MapStateVariant {
233
+ /// A JSON value collection.
234
+ Json(Arc<dyn DynMapState<JsonValue>>),
235
+ /// A Kafka-message collection.
236
+ Message(Arc<dyn DynMapState<ConsumerMessage<JsonValue>>>),
237
+ }
238
+
239
+ /// The two payload flavours a deque handle wraps.
240
+ pub(crate) enum DequeStateVariant {
241
+ /// A JSON value collection.
242
+ Json(Arc<dyn DynDequeState<JsonValue>>),
243
+ /// A Kafka-message collection.
244
+ Message(Arc<dyn DynDequeState<ConsumerMessage<JsonValue>>>),
245
+ }
246
+
247
+ /// Native single-value state handle, vended per event.
248
+ #[magnus::wrap(class = "Prosody::NativeValueState")]
249
+ pub struct NativeValueState {
250
+ state: ValueStateVariant,
251
+ bridge: Bridge,
252
+ propagator: Arc<TextMapCompositePropagator>,
253
+ }
254
+
255
+ impl NativeValueState {
256
+ /// Wraps a vended value handle with the bridge and propagator.
257
+ pub(crate) fn new(
258
+ state: ValueStateVariant,
259
+ bridge: Bridge,
260
+ propagator: Arc<TextMapCompositePropagator>,
261
+ ) -> Self {
262
+ Self {
263
+ state,
264
+ bridge,
265
+ propagator,
266
+ }
267
+ }
268
+
269
+ /// Reads the current value (`nil` when absent).
270
+ fn get(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
271
+ match &this.state {
272
+ ValueStateVariant::Json(handle) => {
273
+ json_or_nil(ruby, run_op!(ruby, this, handle, get())?)
274
+ }
275
+ ValueStateVariant::Message(handle) => {
276
+ message_or_nil(ruby, run_op!(ruby, this, handle, get())?)
277
+ }
278
+ }
279
+ }
280
+
281
+ /// Buffers a write of the value. Rejects JSON null and item-shape mismatch.
282
+ fn set(ruby: &Ruby, this: &Self, value: Value) -> Result<Value, Error> {
283
+ match &this.state {
284
+ ValueStateVariant::Json(handle) => {
285
+ let item = json_write_item(ruby, value, "; use clear to remove the value")?;
286
+ run_op!(ruby, this, handle, set(item))?;
287
+ }
288
+ ValueStateVariant::Message(handle) => {
289
+ let item = message_write_item(
290
+ ruby,
291
+ value,
292
+ "; use clear to delete a message value collection",
293
+ )?;
294
+ run_op!(ruby, this, handle, set(item))?;
295
+ }
296
+ }
297
+ Ok(ruby.qnil().as_value())
298
+ }
299
+
300
+ /// Buffers a clear of the value.
301
+ fn clear(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
302
+ match &this.state {
303
+ ValueStateVariant::Json(handle) => run_op!(ruby, this, handle, clear())?,
304
+ ValueStateVariant::Message(handle) => run_op!(ruby, this, handle, clear())?,
305
+ }
306
+ Ok(ruby.qnil().as_value())
307
+ }
308
+
309
+ /// Durably commits the buffered operations. Returns `nil`: the erased FFI
310
+ /// seam drops the applied/no-op outcome (owner-ratified divergence from the
311
+ /// `:applied|:noop` naming; surfacing it requires a core change).
312
+ fn commit(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
313
+ match &this.state {
314
+ ValueStateVariant::Json(handle) => run_op!(ruby, this, handle, commit())?,
315
+ ValueStateVariant::Message(handle) => run_op!(ruby, this, handle, commit())?,
316
+ }
317
+ Ok(ruby.qnil().as_value())
318
+ }
319
+
320
+ /// Discards the buffered uncommitted operations. Returns `nil` (see
321
+ /// [`commit`](Self::commit)).
322
+ fn rollback(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
323
+ match &this.state {
324
+ ValueStateVariant::Json(handle) => run_infallible!(ruby, this, handle, rollback()),
325
+ ValueStateVariant::Message(handle) => run_infallible!(ruby, this, handle, rollback()),
326
+ }
327
+ Ok(ruby.qnil().as_value())
328
+ }
329
+ }
330
+
331
+ /// Native ordered-map state handle, keyed by `String`, vended per event.
332
+ #[magnus::wrap(class = "Prosody::NativeMapState")]
333
+ pub struct NativeMapState {
334
+ state: MapStateVariant,
335
+ bridge: Bridge,
336
+ propagator: Arc<TextMapCompositePropagator>,
337
+ }
338
+
339
+ impl NativeMapState {
340
+ /// Wraps a vended map handle with the bridge and propagator.
341
+ pub(crate) fn new(
342
+ state: MapStateVariant,
343
+ bridge: Bridge,
344
+ propagator: Arc<TextMapCompositePropagator>,
345
+ ) -> Self {
346
+ Self {
347
+ state,
348
+ bridge,
349
+ propagator,
350
+ }
351
+ }
352
+
353
+ /// Reads the value for `key` (`nil` when absent).
354
+ fn get(ruby: &Ruby, this: &Self, key: String) -> Result<Value, Error> {
355
+ match &this.state {
356
+ MapStateVariant::Json(handle) => {
357
+ json_or_nil(ruby, run_op!(ruby, this, handle, get(key))?)
358
+ }
359
+ MapStateVariant::Message(handle) => {
360
+ message_or_nil(ruby, run_op!(ruby, this, handle, get(key))?)
361
+ }
362
+ }
363
+ }
364
+
365
+ /// Answers whether a stored cell exists for `key`, read through the event's
366
+ /// dirty overlay. No value decode and no resolver run — a message-backed
367
+ /// map answers presence with zero Kafka fetches — but not no-I/O: a
368
+ /// cache miss still reads the store.
369
+ fn contains_key(ruby: &Ruby, this: &Self, key: String) -> Result<Value, Error> {
370
+ let present = match &this.state {
371
+ MapStateVariant::Json(handle) => run_op!(ruby, this, handle, contains_key(key))?,
372
+ MapStateVariant::Message(handle) => run_op!(ruby, this, handle, contains_key(key))?,
373
+ };
374
+ Ok(present.into_value_with(ruby))
375
+ }
376
+
377
+ /// Reads several keys as one isolated batch, one result per input key.
378
+ fn get_many(ruby: &Ruby, this: &Self, keys: Vec<String>) -> Result<Value, Error> {
379
+ match &this.state {
380
+ MapStateVariant::Json(handle) => {
381
+ let items = run_op!(ruby, this, handle, get_many(keys))?;
382
+ let array =
383
+ ruby.ary_try_from_iter(items.into_iter().map(|item| json_or_nil(ruby, item)))?;
384
+ Ok(array.as_value())
385
+ }
386
+ MapStateVariant::Message(handle) => {
387
+ let items = run_op!(ruby, this, handle, get_many(keys))?;
388
+ let array = ruby
389
+ .ary_try_from_iter(items.into_iter().map(|item| message_or_nil(ruby, item)))?;
390
+ Ok(array.as_value())
391
+ }
392
+ }
393
+ }
394
+
395
+ /// Inserts or overwrites `key`. Rejects JSON null and item-shape mismatch.
396
+ fn set(ruby: &Ruby, this: &Self, key: String, value: Value) -> Result<Value, Error> {
397
+ match &this.state {
398
+ MapStateVariant::Json(handle) => {
399
+ let item = json_write_item(ruby, value, "; use delete(key) to remove the entry")?;
400
+ run_op!(ruby, this, handle, set(key, item))?;
401
+ }
402
+ MapStateVariant::Message(handle) => {
403
+ let item = message_write_item(
404
+ ruby,
405
+ value,
406
+ "; use delete(key) to remove a message map entry",
407
+ )?;
408
+ run_op!(ruby, this, handle, set(key, item))?;
409
+ }
410
+ }
411
+ Ok(ruby.qnil().as_value())
412
+ }
413
+
414
+ /// Removes `key`.
415
+ fn remove(ruby: &Ruby, this: &Self, key: String) -> Result<Value, Error> {
416
+ match &this.state {
417
+ MapStateVariant::Json(handle) => run_op!(ruby, this, handle, remove(key))?,
418
+ MapStateVariant::Message(handle) => run_op!(ruby, this, handle, remove(key))?,
419
+ }
420
+ Ok(ruby.qnil().as_value())
421
+ }
422
+
423
+ /// Removes every entry.
424
+ fn clear(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
425
+ match &this.state {
426
+ MapStateVariant::Json(handle) => run_op!(ruby, this, handle, clear())?,
427
+ MapStateVariant::Message(handle) => run_op!(ruby, this, handle, clear())?,
428
+ }
429
+ Ok(ruby.qnil().as_value())
430
+ }
431
+
432
+ /// Opens a cursor over the live entries in key order, yielding `(key,
433
+ /// value)` pairs. Synchronous; the carrier is active while core constructs
434
+ /// the stream span.
435
+ #[allow(clippy::needless_pass_by_value, reason = "Magnus method argument type")]
436
+ fn scan(ruby: &Ruby, this: &Self, direction: String) -> Result<StateScan, Error> {
437
+ let dir = parse_direction(ruby, &direction)?;
438
+ let _guard = extract_opentelemetry_context(ruby, &this.propagator)?.attach();
439
+ let inner = match &this.state {
440
+ MapStateVariant::Json(handle) => ScanInner::MapJson {
441
+ cursor: Arc::from(handle.scan(dir)),
442
+ buffer: VecDeque::new(),
443
+ done: false,
444
+ },
445
+ MapStateVariant::Message(handle) => ScanInner::MapMessage {
446
+ cursor: Arc::from(handle.scan(dir)),
447
+ buffer: VecDeque::new(),
448
+ done: false,
449
+ },
450
+ };
451
+ StateScan::new(
452
+ ruby,
453
+ inner,
454
+ this.bridge.clone(),
455
+ Arc::clone(&this.propagator),
456
+ )
457
+ }
458
+
459
+ /// Opens a cursor over the live keys in key order, yielding bare `String`
460
+ /// keys. Like [`scan`](Self::scan) but skips value decode and the resolver
461
+ /// — a message-backed map enumerates keys with zero Kafka fetches, though
462
+ /// not no-I/O. Synchronous; the carrier is active while core constructs the
463
+ /// stream span.
464
+ #[allow(clippy::needless_pass_by_value, reason = "Magnus method argument type")]
465
+ fn keys(ruby: &Ruby, this: &Self, direction: String) -> Result<StateScan, Error> {
466
+ let dir = parse_direction(ruby, &direction)?;
467
+ let _guard = extract_opentelemetry_context(ruby, &this.propagator)?.attach();
468
+ let inner = match &this.state {
469
+ MapStateVariant::Json(handle) => ScanInner::MapKeys {
470
+ cursor: Arc::from(handle.keys(dir)),
471
+ buffer: VecDeque::new(),
472
+ done: false,
473
+ },
474
+ MapStateVariant::Message(handle) => ScanInner::MapKeys {
475
+ cursor: Arc::from(handle.keys(dir)),
476
+ buffer: VecDeque::new(),
477
+ done: false,
478
+ },
479
+ };
480
+ StateScan::new(
481
+ ruby,
482
+ inner,
483
+ this.bridge.clone(),
484
+ Arc::clone(&this.propagator),
485
+ )
486
+ }
487
+
488
+ /// Durably commits the buffered operations. Returns `nil` (see
489
+ /// [`NativeValueState::commit`]).
490
+ fn commit(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
491
+ match &this.state {
492
+ MapStateVariant::Json(handle) => run_op!(ruby, this, handle, commit())?,
493
+ MapStateVariant::Message(handle) => run_op!(ruby, this, handle, commit())?,
494
+ }
495
+ Ok(ruby.qnil().as_value())
496
+ }
497
+
498
+ /// Discards the buffered uncommitted operations. Returns `nil`.
499
+ fn rollback(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
500
+ match &this.state {
501
+ MapStateVariant::Json(handle) => run_infallible!(ruby, this, handle, rollback()),
502
+ MapStateVariant::Message(handle) => run_infallible!(ruby, this, handle, rollback()),
503
+ }
504
+ Ok(ruby.qnil().as_value())
505
+ }
506
+ }
507
+
508
+ /// Native deque state handle, vended per event.
509
+ #[magnus::wrap(class = "Prosody::NativeDequeState")]
510
+ pub struct NativeDequeState {
511
+ state: DequeStateVariant,
512
+ bridge: Bridge,
513
+ propagator: Arc<TextMapCompositePropagator>,
514
+ }
515
+
516
+ impl NativeDequeState {
517
+ /// Wraps a vended deque handle with the bridge and propagator.
518
+ pub(crate) fn new(
519
+ state: DequeStateVariant,
520
+ bridge: Bridge,
521
+ propagator: Arc<TextMapCompositePropagator>,
522
+ ) -> Self {
523
+ Self {
524
+ state,
525
+ bridge,
526
+ propagator,
527
+ }
528
+ }
529
+
530
+ /// The number of live elements. Ruby Integers are unbounded, so the full
531
+ /// `usize` crosses uncapped.
532
+ fn len(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
533
+ let len = match &this.state {
534
+ DequeStateVariant::Json(handle) => run_op!(ruby, this, handle, len())?,
535
+ DequeStateVariant::Message(handle) => run_op!(ruby, this, handle, len())?,
536
+ };
537
+ Ok(len.into_value_with(ruby))
538
+ }
539
+
540
+ /// Whether the deque holds no live elements.
541
+ fn is_empty(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
542
+ let empty = match &this.state {
543
+ DequeStateVariant::Json(handle) => run_op!(ruby, this, handle, is_empty())?,
544
+ DequeStateVariant::Message(handle) => run_op!(ruby, this, handle, is_empty())?,
545
+ };
546
+ Ok(empty.into_value_with(ruby))
547
+ }
548
+
549
+ /// Reads the element at front-relative `index` (`nil` past the end).
550
+ fn get(ruby: &Ruby, this: &Self, index: usize) -> Result<Value, Error> {
551
+ match &this.state {
552
+ DequeStateVariant::Json(handle) => {
553
+ json_or_nil(ruby, run_op!(ruby, this, handle, get(index))?)
554
+ }
555
+ DequeStateVariant::Message(handle) => {
556
+ message_or_nil(ruby, run_op!(ruby, this, handle, get(index))?)
557
+ }
558
+ }
559
+ }
560
+
561
+ /// Reads the front endpoint slot (`get(0)` without the length round trip),
562
+ /// `nil` when empty. Under a TTL an expired endpoint slot yields `nil` even
563
+ /// when live interior elements remain — a peek never searches inward.
564
+ fn peek_front(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
565
+ match &this.state {
566
+ DequeStateVariant::Json(handle) => {
567
+ json_or_nil(ruby, run_op!(ruby, this, handle, peek_front())?)
568
+ }
569
+ DequeStateVariant::Message(handle) => {
570
+ message_or_nil(ruby, run_op!(ruby, this, handle, peek_front())?)
571
+ }
572
+ }
573
+ }
574
+
575
+ /// Reads the back endpoint slot (`get(len - 1)` without the length round
576
+ /// trip), `nil` when empty. Same endpoint-slot TTL semantics as
577
+ /// [`peek_front`](Self::peek_front).
578
+ fn peek_back(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
579
+ match &this.state {
580
+ DequeStateVariant::Json(handle) => {
581
+ json_or_nil(ruby, run_op!(ruby, this, handle, peek_back())?)
582
+ }
583
+ DequeStateVariant::Message(handle) => {
584
+ message_or_nil(ruby, run_op!(ruby, this, handle, peek_back())?)
585
+ }
586
+ }
587
+ }
588
+
589
+ /// Appends an element at the back. Rejects JSON null and item-shape
590
+ /// mismatch.
591
+ fn push_back(ruby: &Ruby, this: &Self, value: Value) -> Result<Value, Error> {
592
+ match &this.state {
593
+ DequeStateVariant::Json(handle) => {
594
+ let item = json_write_item(ruby, value, " in a deque")?;
595
+ run_op!(ruby, this, handle, push_back(item))?;
596
+ }
597
+ DequeStateVariant::Message(handle) => {
598
+ let item = message_write_item(ruby, value, " to push into a message deque")?;
599
+ run_op!(ruby, this, handle, push_back(item))?;
600
+ }
601
+ }
602
+ Ok(ruby.qnil().as_value())
603
+ }
604
+
605
+ /// Prepends an element at the front. Rejects JSON null and item-shape
606
+ /// mismatch.
607
+ fn push_front(ruby: &Ruby, this: &Self, value: Value) -> Result<Value, Error> {
608
+ match &this.state {
609
+ DequeStateVariant::Json(handle) => {
610
+ let item = json_write_item(ruby, value, " in a deque")?;
611
+ run_op!(ruby, this, handle, push_front(item))?;
612
+ }
613
+ DequeStateVariant::Message(handle) => {
614
+ let item = message_write_item(ruby, value, " to push into a message deque")?;
615
+ run_op!(ruby, this, handle, push_front(item))?;
616
+ }
617
+ }
618
+ Ok(ruby.qnil().as_value())
619
+ }
620
+
621
+ /// Removes and returns the front element (`nil` when empty).
622
+ fn pop_front(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
623
+ match &this.state {
624
+ DequeStateVariant::Json(handle) => {
625
+ json_or_nil(ruby, run_op!(ruby, this, handle, pop_front())?)
626
+ }
627
+ DequeStateVariant::Message(handle) => {
628
+ message_or_nil(ruby, run_op!(ruby, this, handle, pop_front())?)
629
+ }
630
+ }
631
+ }
632
+
633
+ /// Removes and returns the back element (`nil` when empty).
634
+ fn pop_back(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
635
+ match &this.state {
636
+ DequeStateVariant::Json(handle) => {
637
+ json_or_nil(ruby, run_op!(ruby, this, handle, pop_back())?)
638
+ }
639
+ DequeStateVariant::Message(handle) => {
640
+ message_or_nil(ruby, run_op!(ruby, this, handle, pop_back())?)
641
+ }
642
+ }
643
+ }
644
+
645
+ /// Removes every element.
646
+ fn clear(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
647
+ match &this.state {
648
+ DequeStateVariant::Json(handle) => run_op!(ruby, this, handle, clear())?,
649
+ DequeStateVariant::Message(handle) => run_op!(ruby, this, handle, clear())?,
650
+ }
651
+ Ok(ruby.qnil().as_value())
652
+ }
653
+
654
+ /// Opens a cursor over the live elements in index order. Synchronous; the
655
+ /// carrier is active while core constructs the stream span.
656
+ #[allow(clippy::needless_pass_by_value, reason = "Magnus method argument type")]
657
+ fn scan(ruby: &Ruby, this: &Self, direction: String) -> Result<StateScan, Error> {
658
+ let dir = parse_direction(ruby, &direction)?;
659
+ let _guard = extract_opentelemetry_context(ruby, &this.propagator)?.attach();
660
+ let inner = match &this.state {
661
+ DequeStateVariant::Json(handle) => ScanInner::DequeJson {
662
+ cursor: Arc::from(handle.scan(dir)),
663
+ buffer: VecDeque::new(),
664
+ done: false,
665
+ },
666
+ DequeStateVariant::Message(handle) => ScanInner::DequeMessage {
667
+ cursor: Arc::from(handle.scan(dir)),
668
+ buffer: VecDeque::new(),
669
+ done: false,
670
+ },
671
+ };
672
+ StateScan::new(
673
+ ruby,
674
+ inner,
675
+ this.bridge.clone(),
676
+ Arc::clone(&this.propagator),
677
+ )
678
+ }
679
+
680
+ /// Durably commits the buffered operations. Returns `nil` (see
681
+ /// [`NativeValueState::commit`]).
682
+ fn commit(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
683
+ match &this.state {
684
+ DequeStateVariant::Json(handle) => run_op!(ruby, this, handle, commit())?,
685
+ DequeStateVariant::Message(handle) => run_op!(ruby, this, handle, commit())?,
686
+ }
687
+ Ok(ruby.qnil().as_value())
688
+ }
689
+
690
+ /// Discards the buffered uncommitted operations. Returns `nil`.
691
+ fn rollback(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
692
+ match &this.state {
693
+ DequeStateVariant::Json(handle) => run_infallible!(ruby, this, handle, rollback()),
694
+ DequeStateVariant::Message(handle) => run_infallible!(ruby, this, handle, rollback()),
695
+ }
696
+ Ok(ruby.qnil().as_value())
697
+ }
698
+ }
699
+
700
+ /// The four cursor flavours a scan yields, one per (collection, payload) pair.
701
+ ///
702
+ /// Each retains a `buffer` of the items pulled in the current ready-chunk plus
703
+ /// a `done` flag; the erased [`StateCursor`] behind the [`Arc`] owns
704
+ /// exhaustion, error ordering, and close-idempotence.
705
+ enum ScanInner {
706
+ /// A deque JSON scan yielding values.
707
+ DequeJson {
708
+ /// The erased cursor.
709
+ cursor: Arc<StateCursor<JsonValue>>,
710
+ /// Items pulled but not yet yielded.
711
+ buffer: VecDeque<JsonValue>,
712
+ /// Whether the cursor is exhausted.
713
+ done: bool,
714
+ },
715
+ /// A map JSON scan yielding `(key, value)` entries.
716
+ MapJson {
717
+ /// The erased cursor.
718
+ cursor: Arc<StateCursor<(String, JsonValue)>>,
719
+ /// Items pulled but not yet yielded.
720
+ buffer: VecDeque<(String, JsonValue)>,
721
+ /// Whether the cursor is exhausted.
722
+ done: bool,
723
+ },
724
+ /// A deque message scan yielding messages.
725
+ DequeMessage {
726
+ /// The erased cursor.
727
+ cursor: Arc<StateCursor<ConsumerMessage<JsonValue>>>,
728
+ /// Items pulled but not yet yielded.
729
+ buffer: VecDeque<ConsumerMessage<JsonValue>>,
730
+ /// Whether the cursor is exhausted.
731
+ done: bool,
732
+ },
733
+ /// A map message scan yielding `(key, message)` entries.
734
+ MapMessage {
735
+ /// The erased cursor.
736
+ cursor: Arc<StateCursor<(String, ConsumerMessage<JsonValue>)>>,
737
+ /// Items pulled but not yet yielded.
738
+ buffer: VecDeque<(String, ConsumerMessage<JsonValue>)>,
739
+ /// Whether the cursor is exhausted.
740
+ done: bool,
741
+ },
742
+ /// A map key-only scan yielding bare keys (payload-agnostic).
743
+ MapKeys {
744
+ /// The erased cursor.
745
+ cursor: Arc<StateCursor<String>>,
746
+ /// Keys pulled but not yet yielded.
747
+ buffer: VecDeque<String>,
748
+ /// Whether the cursor is exhausted.
749
+ done: bool,
750
+ },
751
+ }
752
+
753
+ /// Yields the next buffered item, or pulls a fresh ready-chunk through
754
+ /// [`Bridge::wait_for`], for one [`ScanInner`] arm. On exhaustion it closes the
755
+ /// cursor (idempotent) and returns `nil`.
756
+ macro_rules! drive_scan {
757
+ ($ruby:expr, $this:expr, $cursor:expr, $buffer:expr, $done:expr, |$item:ident| $conv:block) => {{
758
+ loop {
759
+ if let Some($item) = $buffer.pop_front() {
760
+ return $conv;
761
+ }
762
+ if *$done {
763
+ return Ok($ruby.qnil().as_value());
764
+ }
765
+ let cursor = Arc::clone($cursor);
766
+ let context = extract_opentelemetry_context($ruby, &$this.propagator)?;
767
+ let chunk = $this
768
+ .bridge
769
+ .wait_for(
770
+ $ruby,
771
+ async move {
772
+ cursor
773
+ .next_ready_chunk(SCAN_READY_CHUNK_SIZE)
774
+ .with_context(context)
775
+ .await
776
+ },
777
+ Span::current(),
778
+ )?
779
+ .map_err(|error| state_error($ruby, &error))?;
780
+ match chunk {
781
+ Some(items) => $buffer.extend(items),
782
+ None => {
783
+ *$done = true;
784
+ let cursor = Arc::clone($cursor);
785
+ $this.bridge.wait_for(
786
+ $ruby,
787
+ async move { cursor.close().await },
788
+ Span::current(),
789
+ )?;
790
+ return Ok($ruby.qnil().as_value());
791
+ }
792
+ }
793
+ }
794
+ }};
795
+ }
796
+
797
+ /// Closes the erased cursor for one [`ScanInner`] arm (idempotent).
798
+ ///
799
+ /// Marks the arm terminal and drops any buffered-but-unyielded items *before*
800
+ /// yielding, so a `#next` after `#close` returns `nil` immediately rather than
801
+ /// draining stale items.
802
+ macro_rules! close_scan {
803
+ ($ruby:expr, $this:expr, $cursor:expr, $buffer:expr, $done:expr) => {{
804
+ *$done = true;
805
+ $buffer.clear();
806
+ let cursor = Arc::clone($cursor);
807
+ $this
808
+ .bridge
809
+ .wait_for($ruby, async move { cursor.close().await }, Span::current())?;
810
+ }};
811
+ }
812
+
813
+ /// Item-oriented scan cursor over a map or deque collection.
814
+ ///
815
+ /// `StateScan#next` yields individual items, pulling a fresh ready-chunk from
816
+ /// core when the buffer drains and returning `nil` at exhaustion (unambiguous
817
+ /// under the null ban). A single fiber-aware permit (a one-token
818
+ /// `Thread::Queue` held through [`ThreadSafeValue`]) serializes `#next` and
819
+ /// `#close` in invocation order across chunks: concurrent fibers block on the
820
+ /// permit rather than racing the buffer, an exception does not poison cleanup
821
+ /// (the permit is released on every path), and `#close` cannot run under an
822
+ /// active `#next`. `#close` is idempotent (core-owned) and wired into every
823
+ /// traversal path via the Ruby `ensure`.
824
+ #[magnus::wrap(class = "Prosody::StateScan")]
825
+ pub struct StateScan {
826
+ inner: RefCell<ScanInner>,
827
+ lock: ThreadSafeValue,
828
+ bridge: Bridge,
829
+ propagator: Arc<TextMapCompositePropagator>,
830
+ }
831
+
832
+ impl StateScan {
833
+ /// Builds a scan around an erased cursor, seeding the one-token permit.
834
+ fn new(
835
+ ruby: &Ruby,
836
+ inner: ScanInner,
837
+ bridge: Bridge,
838
+ propagator: Arc<TextMapCompositePropagator>,
839
+ ) -> Result<Self, Error> {
840
+ let queue: Value = ruby.get_inner(&QUEUE_CLASS).funcall(id!(ruby, "new"), ())?;
841
+ let _: Value = queue.funcall(id!(ruby, "push"), (ruby.qnil(),))?;
842
+ Ok(Self {
843
+ inner: RefCell::new(inner),
844
+ lock: ThreadSafeValue::new(queue, bridge.clone()),
845
+ bridge,
846
+ propagator,
847
+ })
848
+ }
849
+
850
+ /// Acquires the permit, fiber-yielding until it is free.
851
+ fn acquire(&self, ruby: &Ruby) -> Result<(), Error> {
852
+ let _: Value = self.lock.get(ruby).funcall(id!(ruby, "pop"), ())?;
853
+ Ok(())
854
+ }
855
+
856
+ /// Releases the permit (best-effort; runs on both success and failure).
857
+ fn release(&self, ruby: &Ruby) {
858
+ let _: Result<Value, Error> = self
859
+ .lock
860
+ .get(ruby)
861
+ .funcall(id!(ruby, "push"), (ruby.qnil(),));
862
+ }
863
+
864
+ /// Yields the next scanned item, or `nil` at exhaustion.
865
+ fn next(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
866
+ this.acquire(ruby)?;
867
+ let out = Self::next_locked(ruby, this);
868
+ this.release(ruby);
869
+ out
870
+ }
871
+
872
+ /// The permit-protected body of [`next`](Self::next).
873
+ fn next_locked(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
874
+ match &mut *this.inner.borrow_mut() {
875
+ ScanInner::DequeJson {
876
+ cursor,
877
+ buffer,
878
+ done,
879
+ } => drive_scan!(ruby, this, cursor, buffer, done, |item| {
880
+ serialize(ruby, &item)
881
+ }),
882
+ ScanInner::MapJson {
883
+ cursor,
884
+ buffer,
885
+ done,
886
+ } => drive_scan!(ruby, this, cursor, buffer, done, |item| {
887
+ let (key, value) = item;
888
+ let value: Value = serialize(ruby, &value)?;
889
+ Ok((key, value).into_value_with(ruby))
890
+ }),
891
+ ScanInner::DequeMessage {
892
+ cursor,
893
+ buffer,
894
+ done,
895
+ } => drive_scan!(ruby, this, cursor, buffer, done, |item| {
896
+ Ok(Message::from(item).into_value_with(ruby))
897
+ }),
898
+ ScanInner::MapMessage {
899
+ cursor,
900
+ buffer,
901
+ done,
902
+ } => drive_scan!(ruby, this, cursor, buffer, done, |item| {
903
+ let (key, message) = item;
904
+ Ok((key, Message::from(message)).into_value_with(ruby))
905
+ }),
906
+ ScanInner::MapKeys {
907
+ cursor,
908
+ buffer,
909
+ done,
910
+ } => drive_scan!(ruby, this, cursor, buffer, done, |item| {
911
+ Ok(item.into_value_with(ruby))
912
+ }),
913
+ }
914
+ }
915
+
916
+ /// Closes the cursor, releasing the underlying stream. Idempotent, and
917
+ /// cannot run under an active `#next` (both take the permit).
918
+ fn close(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
919
+ this.acquire(ruby)?;
920
+ let out = Self::close_locked(ruby, this);
921
+ this.release(ruby);
922
+ out
923
+ }
924
+
925
+ /// The permit-protected body of [`close`](Self::close).
926
+ fn close_locked(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
927
+ match &mut *this.inner.borrow_mut() {
928
+ ScanInner::DequeJson {
929
+ cursor,
930
+ buffer,
931
+ done,
932
+ } => close_scan!(ruby, this, cursor, buffer, done),
933
+ ScanInner::MapJson {
934
+ cursor,
935
+ buffer,
936
+ done,
937
+ } => close_scan!(ruby, this, cursor, buffer, done),
938
+ ScanInner::DequeMessage {
939
+ cursor,
940
+ buffer,
941
+ done,
942
+ } => close_scan!(ruby, this, cursor, buffer, done),
943
+ ScanInner::MapMessage {
944
+ cursor,
945
+ buffer,
946
+ done,
947
+ } => close_scan!(ruby, this, cursor, buffer, done),
948
+ ScanInner::MapKeys {
949
+ cursor,
950
+ buffer,
951
+ done,
952
+ } => close_scan!(ruby, this, cursor, buffer, done),
953
+ }
954
+ Ok(ruby.qnil().as_value())
955
+ }
956
+ }
957
+
958
+ /// Registers the native keyed-state classes and their methods.
959
+ ///
960
+ /// # Errors
961
+ ///
962
+ /// Returns a Magnus error if class or method definition fails.
963
+ pub fn init(ruby: &Ruby) -> Result<(), Error> {
964
+ let module = ruby.get_inner(&ROOT_MOD);
965
+
966
+ let value = module.define_class(id!(ruby, "NativeValueState"), ruby.class_object())?;
967
+ value.define_method(id!(ruby, "get"), method!(NativeValueState::get, 0))?;
968
+ value.define_method(id!(ruby, "set"), method!(NativeValueState::set, 1))?;
969
+ value.define_method(id!(ruby, "clear"), method!(NativeValueState::clear, 0))?;
970
+ value.define_method(id!(ruby, "commit"), method!(NativeValueState::commit, 0))?;
971
+ value.define_method(
972
+ id!(ruby, "rollback"),
973
+ method!(NativeValueState::rollback, 0),
974
+ )?;
975
+
976
+ let map = module.define_class(id!(ruby, "NativeMapState"), ruby.class_object())?;
977
+ map.define_method(id!(ruby, "get"), method!(NativeMapState::get, 1))?;
978
+ map.define_method(
979
+ id!(ruby, "contains_key"),
980
+ method!(NativeMapState::contains_key, 1),
981
+ )?;
982
+ map.define_method(id!(ruby, "get_many"), method!(NativeMapState::get_many, 1))?;
983
+ map.define_method(id!(ruby, "set"), method!(NativeMapState::set, 2))?;
984
+ map.define_method(id!(ruby, "remove"), method!(NativeMapState::remove, 1))?;
985
+ map.define_method(id!(ruby, "clear"), method!(NativeMapState::clear, 0))?;
986
+ map.define_method(id!(ruby, "scan"), method!(NativeMapState::scan, 1))?;
987
+ map.define_method(id!(ruby, "keys"), method!(NativeMapState::keys, 1))?;
988
+ map.define_method(id!(ruby, "commit"), method!(NativeMapState::commit, 0))?;
989
+ map.define_method(id!(ruby, "rollback"), method!(NativeMapState::rollback, 0))?;
990
+
991
+ let deque = module.define_class(id!(ruby, "NativeDequeState"), ruby.class_object())?;
992
+ deque.define_method(id!(ruby, "len"), method!(NativeDequeState::len, 0))?;
993
+ deque.define_method(
994
+ id!(ruby, "is_empty"),
995
+ method!(NativeDequeState::is_empty, 0),
996
+ )?;
997
+ deque.define_method(id!(ruby, "get"), method!(NativeDequeState::get, 1))?;
998
+ deque.define_method(
999
+ id!(ruby, "peek_front"),
1000
+ method!(NativeDequeState::peek_front, 0),
1001
+ )?;
1002
+ deque.define_method(
1003
+ id!(ruby, "peek_back"),
1004
+ method!(NativeDequeState::peek_back, 0),
1005
+ )?;
1006
+ deque.define_method(
1007
+ id!(ruby, "push_back"),
1008
+ method!(NativeDequeState::push_back, 1),
1009
+ )?;
1010
+ deque.define_method(
1011
+ id!(ruby, "push_front"),
1012
+ method!(NativeDequeState::push_front, 1),
1013
+ )?;
1014
+ deque.define_method(
1015
+ id!(ruby, "pop_front"),
1016
+ method!(NativeDequeState::pop_front, 0),
1017
+ )?;
1018
+ deque.define_method(
1019
+ id!(ruby, "pop_back"),
1020
+ method!(NativeDequeState::pop_back, 0),
1021
+ )?;
1022
+ deque.define_method(id!(ruby, "clear"), method!(NativeDequeState::clear, 0))?;
1023
+ deque.define_method(id!(ruby, "scan"), method!(NativeDequeState::scan, 1))?;
1024
+ deque.define_method(id!(ruby, "commit"), method!(NativeDequeState::commit, 0))?;
1025
+ deque.define_method(
1026
+ id!(ruby, "rollback"),
1027
+ method!(NativeDequeState::rollback, 0),
1028
+ )?;
1029
+
1030
+ let scan = module.define_class(id!(ruby, "StateScan"), ruby.class_object())?;
1031
+ scan.define_method(id!(ruby, "next"), method!(StateScan::next, 0))?;
1032
+ scan.define_method(id!(ruby, "close"), method!(StateScan::close, 0))?;
1033
+
1034
+ Ok(())
1035
+ }