prosody 0.5.1 → 0.6.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/.config/rail.toml +26 -0
  3. data/.release-please-manifest.json +1 -1
  4. data/.ruby-version +1 -1
  5. data/.taplo.toml +1 -1
  6. data/AGENTS.md +29 -15
  7. data/CHANGELOG.md +7 -0
  8. data/CONFIGURATION.md +19 -16
  9. data/Cargo.lock +254 -235
  10. data/Cargo.toml +11 -8
  11. data/README.md +100 -23
  12. data/examples/keyed_state.rb +10 -2
  13. data/examples/keyed_state.rbs +1 -0
  14. data/ext/prosody/Cargo.toml +1 -1
  15. data/ext/prosody/src/admin.rs +36 -36
  16. data/ext/prosody/src/bridge/mod.rs +5 -10
  17. data/ext/prosody/src/client/config/connections.rs +170 -0
  18. data/ext/prosody/src/client/config/middleware.rs +184 -0
  19. data/ext/prosody/src/client/config/mod.rs +396 -0
  20. data/ext/prosody/src/client/config/state.rs +323 -0
  21. data/ext/prosody/src/client/mod.rs +31 -117
  22. data/ext/prosody/src/client/readers.rs +106 -0
  23. data/ext/prosody/src/client/request.rs +2 -2
  24. data/ext/prosody/src/client/support.rs +13 -32
  25. data/ext/prosody/src/gvl.rs +8 -6
  26. data/ext/prosody/src/handler/{context.rs → context/mod.rs} +36 -150
  27. data/ext/prosody/src/handler/context/vending.rs +138 -0
  28. data/ext/prosody/src/handler/message.rs +36 -0
  29. data/ext/prosody/src/handler/mod.rs +18 -8
  30. data/ext/prosody/src/handler/state/deque.rs +144 -0
  31. data/ext/prosody/src/handler/state/mod.rs +163 -268
  32. data/ext/prosody/src/handler/state/query.rs +264 -0
  33. data/ext/prosody/src/handler/state/registration.rs +15 -86
  34. data/ext/prosody/src/handler/state/scan.rs +33 -141
  35. data/ext/prosody/src/handler/state/set.rs +98 -0
  36. data/ext/prosody/src/lib.rs +54 -36
  37. data/ext/prosody/src/logging.rs +6 -6
  38. data/ext/prosody/src/published.rs +171 -152
  39. data/ext/prosody/src/scheduler/result.rs +2 -1
  40. data/ext/prosody/src/util.rs +65 -3
  41. data/lib/prosody/client.rb +32 -0
  42. data/lib/prosody/configuration.rb +20 -12
  43. data/lib/prosody/demand.rb +27 -0
  44. data/lib/prosody/native_stubs/client.rb +157 -0
  45. data/lib/prosody/native_stubs/context.rb +178 -0
  46. data/lib/prosody/native_stubs/message.rb +133 -0
  47. data/lib/prosody/native_stubs.rb +11 -956
  48. data/lib/prosody/state/deque.rb +253 -0
  49. data/lib/prosody/state/map.rb +313 -0
  50. data/lib/prosody/state/set.rb +121 -0
  51. data/lib/prosody/state/value.rb +58 -0
  52. data/lib/prosody/state.rb +157 -677
  53. data/lib/prosody/version.rb +1 -1
  54. data/lib/prosody.rb +2 -1
  55. data/sig/configuration.rbs +19 -10
  56. data/sig/prosody.rbs +37 -4
  57. data/sig/published.rbs +102 -0
  58. data/sig/state.rbs +109 -122
  59. data/typecheck/payload_types.rb +12 -0
  60. data/typecheck/payload_types.rbs +1 -0
  61. metadata +30 -11
  62. data/ext/prosody/src/client/config.rs +0 -1300
data/lib/prosody/state.rb CHANGED
@@ -3,7 +3,9 @@
3
3
  module Prosody
4
4
  # Base class for errors raised by keyed-state operations that will not
5
5
  # succeed on retry (an unregistered collection name, an identity mismatch, a
6
- # duplicate registration, an invalid TTL).
6
+ # duplicate registration, an invalid TTL, a JSON `null` write). A `null` is
7
+ # not a storable value. Use `clear` (value, deque) or `delete` (map) to
8
+ # remove one.
7
9
  #
8
10
  # It subclasses {PermanentError} so a rethrown state error is classified as
9
11
  # permanent by the result bridge's `#permanent?` path with no bridge change.
@@ -12,10 +14,10 @@ module Prosody
12
14
  class PermanentStateError < PermanentError; end
13
15
 
14
16
  # Base class for errors raised by keyed-state operations that may succeed on
15
- # retry. Every caller/input mistake (a null write, a wrong item shape, an
16
- # invalid index, an invalid direction token, an unrepresentable value) is
17
- # transient so the message retries and stays visible rather than being
18
- # discarded.
17
+ # retry. Every caller/input mistake that the client detects (a wrong item
18
+ # shape, an invalid index, an invalid direction token, an unrepresentable
19
+ # value) is transient so the message retries and stays visible rather than
20
+ # being discarded.
19
21
  #
20
22
  # It subclasses {TransientError} so a rethrown state error is classified as
21
23
  # transient by the result bridge's `#permanent?` path with no bridge change.
@@ -23,54 +25,31 @@ module Prosody
23
25
  # @see TransientError
24
26
  class TransientStateError < TransientError; end
25
27
 
26
- # Raised when a JSON `null` is written to a collection. `null` is not a
27
- # storable value (it is indistinguishable from absence), so the write is
28
- # rejected and the stored value is left untouched. Use `clear`/`delete` to
29
- # express deletion instead.
30
- #
31
- # It is transient (a caller mistake), so it retries and stays visible.
32
- #
33
- # @see TransientStateError
34
- class NullValueError < TransientStateError; end
35
-
36
28
  # An immutable keyed-state collection definition.
37
29
  #
38
- # Definitions are frozen value objects produced by the {Prosody.value},
39
- # {Prosody.map}, {Prosody.deque}, and their `message_*` siblings. A definition
40
- # both serializes into `Configuration#state_collections` (via
41
- # {#to_state_config}) so the collection is registered before subscribe, and
42
- # drives {Prosody::Context#state} to vend the matching typed handle.
43
- StateAccess = Data.define(:vend_method, :wrapper, :published_vend_method, :published_wrapper)
44
- private_constant :StateAccess
45
- VALUE_ACCESS = StateAccess.new(vend_method: :value_state, wrapper: :ValueState,
46
- published_vend_method: :published_value, published_wrapper: :PublishedValue)
47
- MAP_ACCESS = StateAccess.new(vend_method: :map_state, wrapper: :MapState,
48
- published_vend_method: :published_map, published_wrapper: :PublishedMap)
49
- DEQUE_ACCESS = StateAccess.new(vend_method: :deque_state, wrapper: :DequeState,
50
- published_vend_method: :published_deque, published_wrapper: :PublishedDeque)
51
- MESSAGE_VALUE_ACCESS = StateAccess.new(vend_method: :message_value_state, wrapper: :ValueState,
52
- published_vend_method: nil, published_wrapper: nil)
53
- MESSAGE_MAP_ACCESS = StateAccess.new(vend_method: :message_map_state, wrapper: :MapState,
54
- published_vend_method: nil, published_wrapper: nil)
55
- MESSAGE_DEQUE_ACCESS = StateAccess.new(vend_method: :message_deque_state, wrapper: :DequeState,
56
- published_vend_method: nil, published_wrapper: nil)
57
- private_constant :VALUE_ACCESS, :MAP_ACCESS, :DEQUE_ACCESS,
58
- :MESSAGE_VALUE_ACCESS, :MESSAGE_MAP_ACCESS, :MESSAGE_DEQUE_ACCESS
59
-
30
+ # The {Prosody.value}, {Prosody.map}, {Prosody.set}, and {Prosody.deque}
31
+ # constructors and their `message_*` siblings return frozen definitions. A
32
+ # definition serializes into `Configuration#state_collections` through
33
+ # {#to_state_config}, so the collection is registered before subscribe.
34
+ # {Prosody::Context#state} uses it to open the matching typed handle, and
35
+ # {Prosody::Client#state} uses it to open a published reader.
60
36
  StateDefinition = Data.define(:name, :kind, :payload, :ttl_seconds, :read_uncommitted,
61
37
  :published, :read_cache, :keyset_limit, :capacity, :access) do
38
+ # Every option defaults to +nil+, so a constructor passes only the
39
+ # options its kind takes.
40
+ def initialize(name:, kind:, access:, payload: nil, ttl_seconds: nil, read_uncommitted: nil,
41
+ published: nil, read_cache: nil, keyset_limit: nil, capacity: nil)
42
+ super
43
+ end
44
+
62
45
  # Serializes this definition into the native-registration hash, omitting
63
- # unset optionals so they fall back to the core defaults.
46
+ # unset optionals so they fall back to the core defaults. A set has no
47
+ # payload, so its hash has no payload key.
64
48
  #
65
49
  # @return [Hash] the registration hash for the native layer
66
50
  def to_state_config
67
- config = {name: name, kind: kind, payload: payload}
68
- config[:ttl_seconds] = ttl_seconds unless ttl_seconds.nil?
69
- config[:read_uncommitted] = read_uncommitted unless read_uncommitted.nil?
70
- config[:published] = published unless published.nil?
71
- config[:keyset_limit] = keyset_limit unless keyset_limit.nil?
72
- config[:capacity] = capacity unless capacity.nil?
73
- config
51
+ to_h.slice(:name, :kind, :payload, :ttl_seconds, :read_uncommitted, :published, :keyset_limit,
52
+ :capacity).compact
74
53
  end
75
54
  end
76
55
 
@@ -79,12 +58,14 @@ module Prosody
79
58
  # @param name [#to_s] the collection name (unique within the client)
80
59
  # @param ttl [Integer, nil] optional per-write TTL in whole seconds
81
60
  # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
61
+ # @param published [Boolean, nil] allow read-only access from other consumer groups
62
+ # @param read_cache [Numeric, false, nil] cache TTL in seconds for the readers
63
+ # that +client.state+ opens, +false+ to bypass the cache, or +nil+ to
64
+ # inherit the client default
82
65
  # @return [StateDefinition] a frozen definition
83
66
  def self.value(name, ttl: nil, read_uncommitted: nil, published: nil, read_cache: nil)
84
- StateDefinition.new(name: name.to_s, kind: "value", payload: "json",
85
- ttl_seconds: ttl, read_uncommitted: read_uncommitted, published: published,
86
- read_cache: read_cache, keyset_limit: nil, capacity: nil,
87
- access: VALUE_ACCESS)
67
+ StateDefinition.new(name: name.to_s, kind: "value", payload: "json", ttl_seconds: ttl,
68
+ read_uncommitted: read_uncommitted, published: published, read_cache: read_cache, access: VALUE_ACCESS)
88
69
  end
89
70
 
90
71
  # Defines a `String`-keyed ordered map JSON collection.
@@ -93,12 +74,32 @@ module Prosody
93
74
  # @param ttl [Integer, nil] optional per-write TTL in whole seconds
94
75
  # @param keyset_limit [Integer, nil] optional map-only keyset bound (`0..=4096`)
95
76
  # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
77
+ # @param published [Boolean, nil] allow read-only access from other consumer groups
78
+ # @param read_cache [Numeric, false, nil] cache TTL in seconds for the readers
79
+ # that +client.state+ opens, +false+ to bypass the cache, or +nil+ to
80
+ # inherit the client default
96
81
  # @return [StateDefinition] a frozen definition
97
82
  def self.map(name, ttl: nil, keyset_limit: nil, read_uncommitted: nil, published: nil, read_cache: nil)
98
- StateDefinition.new(name: name.to_s, kind: "map", payload: "json",
99
- ttl_seconds: ttl, read_uncommitted: read_uncommitted, published: published,
100
- read_cache: read_cache, keyset_limit: keyset_limit, capacity: nil,
101
- access: MAP_ACCESS)
83
+ StateDefinition.new(name: name.to_s, kind: "map", payload: "json", ttl_seconds: ttl,
84
+ keyset_limit: keyset_limit, read_uncommitted: read_uncommitted, published: published,
85
+ read_cache: read_cache, access: MAP_ACCESS)
86
+ end
87
+
88
+ # Defines an ordered set of String members. A set stores membership only,
89
+ # so it has no payload.
90
+ #
91
+ # @param name [#to_s] the collection name (unique within the client)
92
+ # @param ttl [Integer, nil] optional per-write TTL in whole seconds
93
+ # @param keyset_limit [Integer, nil] optional keyset bound (`0..=4096`)
94
+ # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
95
+ # @param published [Boolean, nil] allow read-only access from other consumer groups
96
+ # @param read_cache [Numeric, false, nil] cache TTL in seconds for the readers
97
+ # that +client.state+ opens, +false+ to bypass the cache, or +nil+ to
98
+ # inherit the client default
99
+ # @return [StateDefinition] a frozen definition
100
+ def self.set(name, ttl: nil, keyset_limit: nil, read_uncommitted: nil, published: nil, read_cache: nil)
101
+ StateDefinition.new(name: name.to_s, kind: "set", ttl_seconds: ttl, keyset_limit: keyset_limit,
102
+ read_uncommitted: read_uncommitted, published: published, read_cache: read_cache, access: SET_ACCESS)
102
103
  end
103
104
 
104
105
  # Defines a deque JSON collection.
@@ -109,12 +110,15 @@ module Prosody
109
110
  # deque keeps at most this many slots, enforced lazily on push. Runtime-only
110
111
  # and mutable across deploys, never persisted (see {DequeState#push}).
111
112
  # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
113
+ # @param published [Boolean, nil] allow read-only access from other consumer groups
114
+ # @param read_cache [Numeric, false, nil] cache TTL in seconds for the readers
115
+ # that +client.state+ opens, +false+ to bypass the cache, or +nil+ to
116
+ # inherit the client default
112
117
  # @return [StateDefinition] a frozen definition
113
118
  def self.deque(name, ttl: nil, capacity: nil, read_uncommitted: nil, published: nil, read_cache: nil)
114
- StateDefinition.new(name: name.to_s, kind: "deque", payload: "json",
115
- ttl_seconds: ttl, read_uncommitted: read_uncommitted, published: published,
116
- read_cache: read_cache, keyset_limit: nil, capacity: capacity,
117
- access: DEQUE_ACCESS)
119
+ StateDefinition.new(name: name.to_s, kind: "deque", payload: "json", ttl_seconds: ttl,
120
+ capacity: capacity, read_uncommitted: read_uncommitted, published: published,
121
+ read_cache: read_cache, access: DEQUE_ACCESS)
118
122
  end
119
123
 
120
124
  # Defines a single-value Kafka-message collection (items are full messages).
@@ -124,10 +128,8 @@ module Prosody
124
128
  # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
125
129
  # @return [StateDefinition] a frozen definition
126
130
  def self.message_value(name, ttl: nil, read_uncommitted: nil)
127
- StateDefinition.new(name: name.to_s, kind: "value", payload: "message",
128
- ttl_seconds: ttl, read_uncommitted: read_uncommitted, published: nil,
129
- read_cache: nil, keyset_limit: nil, capacity: nil,
130
- access: MESSAGE_VALUE_ACCESS)
131
+ StateDefinition.new(name: name.to_s, kind: "value", payload: "message", ttl_seconds: ttl,
132
+ read_uncommitted: read_uncommitted, access: MESSAGE_VALUE_ACCESS)
131
133
  end
132
134
 
133
135
  # Defines a `String`-keyed ordered map Kafka-message collection.
@@ -138,10 +140,8 @@ module Prosody
138
140
  # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
139
141
  # @return [StateDefinition] a frozen definition
140
142
  def self.message_map(name, ttl: nil, keyset_limit: nil, read_uncommitted: nil)
141
- StateDefinition.new(name: name.to_s, kind: "map", payload: "message",
142
- ttl_seconds: ttl, read_uncommitted: read_uncommitted, published: nil,
143
- read_cache: nil, keyset_limit: keyset_limit, capacity: nil,
144
- access: MESSAGE_MAP_ACCESS)
143
+ StateDefinition.new(name: name.to_s, kind: "map", payload: "message", ttl_seconds: ttl,
144
+ keyset_limit: keyset_limit, read_uncommitted: read_uncommitted, access: MESSAGE_MAP_ACCESS)
145
145
  end
146
146
 
147
147
  # Defines a deque Kafka-message collection.
@@ -154,25 +154,47 @@ module Prosody
154
154
  # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
155
155
  # @return [StateDefinition] a frozen definition
156
156
  def self.message_deque(name, ttl: nil, capacity: nil, read_uncommitted: nil)
157
- StateDefinition.new(name: name.to_s, kind: "deque", payload: "message",
158
- ttl_seconds: ttl, read_uncommitted: read_uncommitted, published: nil,
159
- read_cache: nil, keyset_limit: nil, capacity: capacity,
160
- access: MESSAGE_DEQUE_ACCESS)
157
+ StateDefinition.new(name: name.to_s, kind: "deque", payload: "message", ttl_seconds: ttl,
158
+ capacity: capacity, read_uncommitted: read_uncommitted, access: MESSAGE_DEQUE_ACCESS)
161
159
  end
162
160
 
163
161
  # Shared state wrapper behavior.
164
162
  module State
163
+ # The base class of the owned handles. It holds the native handle and gives
164
+ # the commit and rollback that every collection shares.
165
+ #
166
+ # @api private
167
+ class Handle
168
+ # @param native [Object] the native handle
169
+ def initialize(native)
170
+ @native = native
171
+ end
172
+
173
+ # Durably commits the buffered operations mid-handler.
174
+ #
175
+ # @return [Symbol] +:applied+ when buffered operations were written, or
176
+ # +:no_op+ when nothing was buffered
177
+ def commit = @native.commit
178
+
179
+ # Discards the buffered uncommitted operations.
180
+ #
181
+ # @return [Symbol] +:applied+ when buffered operations were discarded, or
182
+ # +:no_op+ when nothing was buffered
183
+ def rollback = @native.rollback
184
+ end
185
+
165
186
  module Reading
166
- # Opens a read-only view of a published JSON collection.
187
+ # Opens a read-only view of a published JSON or set collection.
188
+ #
189
+ # @raise [TransientStateError, PermanentStateError] if Prosody cannot
190
+ # open the reader, for example for a zero +read_cache+
167
191
  def state(subsystem, definition)
168
- access = definition.access
169
- if access.published_vend_method.nil? || access.published_wrapper.nil?
170
- raise ArgumentError, "published state readers support JSON collections only"
171
- end
172
- cache_seconds = definition.read_cache unless definition.read_cache == false
173
- native = public_send(access.published_vend_method, subsystem.to_s, definition.name, cache_seconds,
174
- definition.read_cache == false)
175
- Prosody.const_get(access.published_wrapper).new(native)
192
+ open, reader = definition.access.reader
193
+ raise ArgumentError, "published state readers support JSON and set collections only" unless open
194
+
195
+ cache = definition.read_cache
196
+ cache = Float(cache) unless cache.nil? || cache == true || cache == false
197
+ reader.new(send(open, subsystem.to_s, definition.name, cache))
176
198
  end
177
199
  end
178
200
 
@@ -182,21 +204,16 @@ module Prosody
182
204
  module Vending
183
205
  # Vends the typed keyed-state handle for `definition`.
184
206
  #
185
- # Handles are cached per context by kind, payload, and name, so repeated
186
- # vends within one handler invocation return the same wrapper.
207
+ # Handles are cached per context by definition, so repeated vends within
208
+ # one handler invocation return the same wrapper.
187
209
  #
188
210
  # @param definition [StateDefinition] a frozen collection definition
189
- # @return [ValueState, MapState, DequeState] the typed handle
190
- # @raise [TransientStateError] if the definition's kind/payload is unknown
211
+ # @return [ValueState, MapState, SetState, DequeState] the typed handle
191
212
  # @raise [PermanentStateError] if the collection name is unregistered or
192
213
  # its registered identity mismatches
193
214
  def state(definition)
194
- cache = (@state_handles ||= {})
195
- cache_key = "#{definition.kind}:#{definition.payload}:#{definition.name}"
196
- return cache[cache_key] if cache.key?(cache_key)
197
-
198
- native = public_send(definition.access.vend_method, definition.name)
199
- cache[cache_key] = Prosody.const_get(definition.access.wrapper).new(native)
215
+ (@state_handles ||= {})[definition] ||=
216
+ definition.access.wrapper.new(send(definition.access.vend_method, definition.name))
200
217
  end
201
218
  end
202
219
 
@@ -204,613 +221,76 @@ module Prosody
204
221
  # identical native-scan open/close/exhaustion loop; each handle supplies
205
222
  # only the per-item yield shape through the block. Kept private (mixed into
206
223
  # the handle classes) since it is not part of the public surface.
224
+ #
225
+ # Every traversal method accepts optional query keywords. Map and set
226
+ # traversals take `from:`, `after:`, `to:`, `before:`, `range:`,
227
+ # `prefix:`, and `limit:` over String keys. Deque traversals take the same
228
+ # keywords without `prefix:`, over non-negative positions from the front.
229
+ # `from:`/`after:` start and `to:`/`before:` stop in iteration order, so a
230
+ # reverse traversal starts at the high end. `range:` takes a Ruby `Range`
231
+ # (inclusive, exclusive, beginless, or endless) in ascending order and
232
+ # applies in either direction. A descending `Range` is empty. Every
233
+ # keyword narrows the selection. `limit:` counts yielded
234
+ # items. The native layer translates the keywords and raises
235
+ # `ArgumentError` or `TypeError` for a bad one.
207
236
  module Scanning
208
237
  private
209
238
 
210
- # Opens a native scan in `direction`, yields each item, and closes the
211
- # scan via `ensure` on stop or exception. Direction validity is enforced
212
- # by the native layer (an invalid token is rejected transient there); the
213
- # public traversal methods only ever pass `:forward`/`:backward`. `opener`
214
- # selects the native cursor seam — the default `:scan` yields values (or
215
- # `[key, value]` pairs), `:keys` yields bare map keys.
216
- def scan_each(direction, opener = :scan)
217
- scan_items(@native.public_send(opener, direction)) { |item| yield item }
218
- end
239
+ # Opens the native cursor `opener` (`:scan` or `:keys`) with `args`,
240
+ # yields each item, and closes the cursor via `ensure` on stop or
241
+ # exception. Without a block, returns an Enumerator. A map scan yields
242
+ # each `[key, value]` pair as one Array, as `Hash#each_pair` does.
243
+ def traverse(opener, *args, &block)
244
+ return enum_for(:traverse, opener, *args) unless block
219
245
 
220
- def scan_items(scan)
221
- # `nil` is the exhaustion sentinel (unambiguous under the null ban);
222
- # terminate on it explicitly rather than on falsiness, so a legal
223
- # stored `false` (or a `[key, false]` pair, always a truthy Array)
224
- # does not stop iteration and drop the tail after it.
225
- until (item = scan.next).nil?
226
- yield item
246
+ scan = @native.public_send(opener, *args)
247
+ while (chunk = scan.next_chunk)
248
+ chunk.each(&block)
227
249
  end
228
250
  ensure
229
- scan.close
251
+ scan&.close
230
252
  end
231
- end
232
- end
233
-
234
- class Client
235
- include State::Reading
236
- end
237
-
238
- class PublishedValue
239
- def initialize(native) = @native = native
240
- def get(key) = @native.get(key.to_s)
241
- end
242
-
243
- class PublishedMap
244
- include State::Scanning
245
-
246
- def initialize(native) = @native = native
247
- def get(key, map_key) = @native.get(key.to_s, map_key.to_s)
248
- def get_many(key, map_keys) = @native.get_many(key.to_s, map_keys.map(&:to_s))
249
- def key?(key, map_key) = @native.contains_key(key.to_s, map_key.to_s)
250
- alias_method :has_key?, :key?
251
- alias_method :include?, :key?
252
- alias_method :member?, :key?
253
-
254
- def each_pair(key, &block) = traverse(key, :forward, &block)
255
- def reverse_each_pair(key, &block) = traverse(key, :backward, &block)
256
- def each_key(key, &block) = traverse_keys(key, :forward, &block)
257
- def reverse_each_key(key, &block) = traverse_keys(key, :backward, &block)
258
- def each_value(key, &block) = traverse_values(key, :forward, &block)
259
- def reverse_each_value(key, &block) = traverse_values(key, :backward, &block)
260
- alias_method :each, :each_pair
261
253
 
262
- private
263
-
264
- def traverse(key, direction)
265
- return enum_for(__method__, key, direction) unless block_given?
266
-
267
- scan_items(@native.scan(key.to_s, direction)) { |entry| yield(*entry) }
268
- end
254
+ # Traverses a map scan like {#traverse} and yields only each value.
255
+ def traverse_values(*args)
256
+ return enum_for(:traverse_values, *args) unless block_given?
269
257
 
270
- def traverse_keys(key, direction)
271
- return enum_for(__method__, key, direction) unless block_given?
272
-
273
- scan_items(@native.keys(key.to_s, direction)) { |map_key| yield map_key }
274
- end
275
-
276
- def traverse_values(key, direction)
277
- return enum_for(__method__, key, direction) unless block_given?
278
-
279
- scan_items(@native.scan(key.to_s, direction)) { |entry| yield entry[1] }
280
- end
281
- end
282
-
283
- class PublishedDeque
284
- include State::Scanning
285
-
286
- def initialize(native) = @native = native
287
-
288
- def get(key, index)
289
- unless index.is_a?(Integer)
290
- raise TransientStateError, "get: index must be an Integer, got #{index.inspect}"
258
+ traverse(:scan, *args) { |entry| yield entry[1] }
291
259
  end
292
-
293
- return @native.get(key.to_s, index) unless index.negative?
294
- return last(key) if index == -1
295
-
296
- resolved = length(key) + index
297
- resolved.negative? ? nil : @native.get(key.to_s, resolved)
298
- end
299
-
300
- def length(key) = @native.length(key.to_s)
301
- alias_method :size, :length
302
- def empty?(key) = @native.is_empty(key.to_s)
303
- def first(key) = @native.peek_front(key.to_s)
304
- def last(key) = @native.peek_back(key.to_s)
305
-
306
- def each(key, &block) = traverse(key, :forward, &block)
307
- def reverse_each(key, &block) = traverse(key, :backward, &block)
308
-
309
- private
310
-
311
- def traverse(key, direction)
312
- return enum_for(__method__, key, direction) unless block_given?
313
-
314
- scan_items(@native.scan(key.to_s, direction)) { |item| yield item }
315
260
  end
316
261
  end
317
262
 
318
- # A single-value keyed-state handle.
319
- #
320
- # Reads return the stored JSON value (or a {Prosody::Message} for message
321
- # collections), or `nil` when the value is absent. Writes are buffered and
322
- # made durable by {#commit}. All operations are fiber-yield async: they look
323
- # blocking but never block the thread.
324
- class ValueState
325
- # @param native [Prosody::NativeJsonValueState, Prosody::NativeMessageValueState] the native handle
326
- def initialize(native)
327
- @native = native
328
- end
329
-
330
- # Reads the current value.
331
- #
332
- # @return [Object, nil] the stored value, or `nil` when absent
333
- def get = @native.get
334
-
335
- # Buffers a write of the value.
336
- #
337
- # @param value [Object] the value to store (JSON, or a message)
338
- # @return [void]
339
- # @raise [NullValueError] if `value` is `nil` (use {#clear} to delete)
340
- def set(value) = @native.set(value)
341
-
342
- # Buffers a clear of the value.
343
- #
344
- # @return [void]
345
- def clear = @native.clear
346
-
347
- # Durably commits the buffered operations mid-handler.
348
- #
349
- # @return [nil] the erased FFI seam drops the applied/no-op outcome
350
- def commit = @native.commit
351
-
352
- # Discards the buffered uncommitted operations.
353
- #
354
- # @return [nil]
355
- def rollback = @native.rollback
356
-
357
- # Reads the current value. Idiomatic alias of {#get}.
358
- #
359
- # @return [Object, nil]
360
- alias_method :value, :get
361
-
362
- # Buffers a write of the value. Idiomatic alias of {#set}. As with any Ruby
363
- # writer, `state.value = x` evaluates to `x` regardless of the return.
364
- #
365
- # @param value [Object]
366
- # @return [void]
367
- alias_method :value=, :set
368
- end
369
-
370
- # A `String`-keyed ordered-map keyed-state handle.
371
- #
372
- # Traversal is explicit: {#each_pair}/{#reverse_each_pair} yield `key, value`
373
- # pairs over a native scan, closing the scan via `ensure`. No aggregate-mixin
374
- # methods are provided — they would silently materialize an unbounded remote
375
- # collection.
376
- class MapState
377
- include State::Scanning
378
-
379
- # @param native [Prosody::NativeJsonMapState, Prosody::NativeMessageMapState] the native handle
380
- def initialize(native)
381
- @native = native
382
- end
383
-
384
- # Reads the value for `key`.
385
- #
386
- # @param key [String] the map key
387
- # @return [Object, nil] the value, or `nil` when the key is absent
388
- def get(key) = @native.get(key)
389
-
390
- # Reads several keys in a single isolated batch.
391
- #
392
- # @param keys [Array<String>] the keys to read, in order
393
- # @return [Array<Object, nil>] one result per input key; `nil` for absent keys
394
- def get_many(keys) = @native.get_many(keys)
395
-
396
- # Inserts or overwrites `key`.
397
- #
398
- # @param key [String] the map key
399
- # @param value [Object] the value to store (JSON, or a message)
400
- # @return [void]
401
- # @raise [NullValueError] if `value` is `nil` (use {#delete} to remove)
402
- def set(key, value) = @native.set(key, value)
403
-
404
- # Removes `key`.
405
- #
406
- # Documented divergence from `Hash#delete`: this returns `nil`, never the
407
- # removed value (the erased FFI seam does not surface it).
408
- #
409
- # @param key [String] the map key
410
- # @return [nil]
411
- def delete(key)
412
- @native.remove(key)
413
- nil
414
- end
415
-
416
- # Removes every entry.
417
- #
418
- # @return [void]
419
- def clear = @native.clear
420
-
421
- # Durably commits the buffered operations mid-handler.
422
- #
423
- # @return [nil] the erased FFI seam drops the applied/no-op outcome
424
- def commit = @native.commit
425
-
426
- # Discards the buffered uncommitted operations.
427
- #
428
- # @return [nil]
429
- def rollback = @native.rollback
430
-
431
- # Traverses the live entries in key order, yielding `key, value`.
432
- #
433
- # Without a block, returns an {Enumerator} over the native scan. Each step
434
- # fiber-yields; the scan is closed via `ensure` on stop or exception. The
435
- # enumerator is valid only within the current handler invocation.
436
- #
437
- # @yieldparam key [String]
438
- # @yieldparam value [Object]
439
- # @return [Enumerator, void]
440
- def each_pair(&block) = traverse(:forward, &block)
441
-
442
- # Traverses the live entries in reverse key order, yielding `key, value`.
443
- #
444
- # @yieldparam key [String]
445
- # @yieldparam value [Object]
446
- # @return [Enumerator, void]
447
- def reverse_each_pair(&block) = traverse(:backward, &block)
448
-
449
- # Traverses the live keys in key order, yielding each key (mirrors
450
- # +Hash#each_key+). The key scan skips value decode and the resolver — a
451
- # message-backed map yields keys with zero Kafka fetches, though not
452
- # zero-I/O. Without a block, returns a demand-driven {Enumerator}; there is
453
- # deliberately no eager +keys+ array (it would materialize the whole remote
454
- # keyset). Mirrors {#each_pair}'s block-form return (+nil+), not stdlib's
455
- # +self+, for in-repo sibling consistency.
456
- #
457
- # @yieldparam key [String]
458
- # @return [Enumerator, void]
459
- def each_key(&block) = traverse_keys(:forward, &block)
460
-
461
- # Traverses the live keys in reverse key order, yielding each key.
462
- #
463
- # @yieldparam key [String]
464
- # @return [Enumerator, void]
465
- def reverse_each_key(&block) = traverse_keys(:backward, &block)
466
- def each_value(&block) = traverse_values(:forward, &block)
467
- def reverse_each_value(&block) = traverse_values(:backward, &block)
468
-
469
- # --- idiomatic Hash-style aliases and conveniences ------------------
470
- # Each is composed from the canonical ops above and adds no capability
471
- # the naming matrix lacks. Bounded reads only: there is deliberately no
472
- # +keys+/+values+/+to_h+/+count+ or +Enumerable+, which would materialize
473
- # the whole (potentially unbounded) remote collection.
474
-
475
- # Reads +key+. Idiomatic alias of {#get} (mirrors +Hash#[]+).
476
- alias_method :[], :get
477
-
478
- # Writes +key+. Idiomatic alias of {#set} (mirrors +Hash#[]=+). As with any
479
- # Ruby +[]=+, `map[key] = value` evaluates to +value+ regardless of return.
480
- alias_method :[]=, :set
481
-
482
- # Writes +key+, returning the stored +value+ (mirrors +Hash#store+). A
483
- # wrapper, not an alias: unlike +[]=+, +store+ is called normally, so its
484
- # return is observed — and the native write returns +nil+.
485
- #
486
- # @param key [String]
487
- # @param value [Object]
488
- # @return [Object] the stored +value+
489
- def store(key, value)
490
- set(key, value)
491
- value
492
- end
493
-
494
- # Traverses live entries in key order. Idiomatic alias of {#each_pair}
495
- # (mirrors +Hash#each+).
496
- alias_method :each, :each_pair
497
-
498
- # Reads several keys positionally (mirrors +Hash#values_at+).
499
- #
500
- # @param keys [Array<String>] the keys to read
501
- # @return [Array<Object, nil>] one result per key; +nil+ for absent keys
502
- def values_at(*keys) = get_many(keys)
503
-
504
- # Reads +key+, raising or defaulting when absent (mirrors +Hash#fetch+).
505
- # Performs a single read; a +nil+ result is unambiguously "absent" under
506
- # the null ban.
507
- #
508
- # @param key [String]
509
- # @param default [Object] returned when +key+ is absent
510
- # @yieldparam key [String] called (instead of +default+) when +key+ is absent
511
- # @return [Object]
512
- # @raise [KeyError] when +key+ is absent and no default or block is given
513
- def fetch(key, *default, &block)
514
- if default.length > 1
515
- raise ArgumentError, "wrong number of arguments (given #{default.length + 1}, expected 1..2)"
516
- end
517
- warn "warning: block supersedes default value argument" if block && !default.empty?
518
-
519
- value = @native.get(key)
520
- return value unless value.nil?
521
- return block.call(key) if block
522
- return default.first unless default.empty?
523
-
524
- raise KeyError.new("key not found: #{key.inspect}", key: key, receiver: self)
525
- end
526
-
527
- # Whether +key+ has a live value (mirrors +Hash#key?+). A presence check:
528
- # no value decode and no resolver run (not no-I/O). A message-backed map
529
- # answers presence with zero Kafka fetches — +true+ even for a
530
- # present-but-unfetchable cell — though a cache miss may still touch the
531
- # store.
532
- #
533
- # @param key [String]
534
- # @return [Boolean]
535
- def key?(key) = @native.contains_key(key)
536
- alias_method :has_key?, :key?
537
- alias_method :include?, :key?
538
- alias_method :member?, :key?
539
-
540
- # Reads +key+ and digs into the nested value (mirrors +Hash#dig+). A single
541
- # bounded read; digging continues in the returned local value.
542
- #
543
- # @param key [String]
544
- # @return [Object, nil]
545
- # @raise [TypeError] if a nested value does not respond to +dig+
546
- def dig(key, *rest)
547
- value = @native.get(key)
548
- return value if rest.empty? || value.nil?
549
-
550
- unless value.respond_to?(:dig)
551
- raise TypeError, "#{value.class} does not have #dig method"
552
- end
553
-
554
- value.dig(*rest)
555
- end
556
-
557
- # Reads +keys+ as a single bounded batch, returning a +Hash+ of only the
558
- # keys that are present (mirrors +Hash#slice+). Absent keys are omitted.
559
- #
560
- # @param keys [Array<String>] the keys to read
561
- # @return [Hash{String => Object}] present keys mapped to their values
562
- def slice(*keys)
563
- result = {}
564
- keys.zip(get_many(keys)) do |key, value|
565
- result[key] = value unless value.nil?
566
- end
567
- result
568
- end
569
-
570
- # Reads +keys+ as a single bounded batch, requiring every key to be present
571
- # (mirrors +Hash#fetch_values+). Without a block, a missing key raises
572
- # {KeyError}; with a block, the block is called with each missing key and
573
- # its result substituted.
574
- #
575
- # @param keys [Array<String>] the keys to read, in order
576
- # @yieldparam key [String] called for each absent key
577
- # @return [Array<Object>] one value per key, in order
578
- # @raise [KeyError] when a key is absent and no block is given
579
- def fetch_values(*keys, &block)
580
- keys.zip(get_many(keys)).map do |key, value|
581
- next value unless value.nil?
582
- next block.call(key) if block
583
-
584
- raise KeyError.new("key not found: #{key.inspect}", key: key, receiver: self)
585
- end
586
- end
587
-
588
- private
589
-
590
- def traverse(direction)
591
- return enum_for(:traverse, direction) unless block_given?
592
-
593
- # Yield the [key, value] pair as a single Array, matching Hash#each_pair:
594
- # a two-parameter block auto-splats it (|k, v|), a one-parameter block
595
- # receives the pair (|pair|), and the no-block Enumerator yields pairs.
596
- scan_each(direction) { |pair| yield pair }
597
- end
598
-
599
- def traverse_keys(direction)
600
- return enum_for(:traverse_keys, direction) unless block_given?
601
-
602
- scan_each(direction, :keys) { |key| yield key }
603
- end
604
-
605
- def traverse_values(direction)
606
- return enum_for(:traverse_values, direction) unless block_given?
607
-
608
- scan_each(direction) { |entry| yield entry[1] }
609
- end
610
- end
611
-
612
- # A deque keyed-state handle.
613
- #
614
- # Traversal is explicit: {#each}/{#reverse_each} yield single elements over a
615
- # native scan, closing the scan via `ensure`. No aggregate-mixin methods are
616
- # provided.
617
- class DequeState
618
- include State::Scanning
619
-
620
- # @param native [Prosody::NativeJsonDequeState, Prosody::NativeMessageDequeState] the native handle
621
- def initialize(native)
622
- @native = native
623
- end
624
-
625
- # Appends an element at the back.
626
- #
627
- # @param value [Object] the element (JSON, or a message)
628
- # @return [void]
629
- # @raise [NullValueError] if `value` is `nil`
630
- def push(value) = @native.push_back(value)
631
-
632
- # Prepends an element at the front.
633
- #
634
- # @param value [Object] the element (JSON, or a message)
635
- # @return [void]
636
- # @raise [NullValueError] if `value` is `nil`
637
- def unshift(value) = @native.push_front(value)
638
-
639
- # Removes and returns the back element.
640
- #
641
- # @return [Object, nil] the removed element, or `nil` when empty
642
- def pop = @native.pop_back
643
-
644
- # Removes and returns the front element.
645
- #
646
- # @return [Object, nil] the removed element, or `nil` when empty
647
- def shift = @native.pop_front
648
-
649
- # The number of live elements.
650
- #
651
- # @return [Integer]
652
- def length = @native.len
653
-
654
- alias_method :size, :length
655
-
656
- # Whether the deque holds no live elements.
657
- #
658
- # @return [Boolean]
659
- def empty? = @native.is_empty
660
-
661
- # Removes every element.
662
- #
663
- # @return [void]
664
- def clear = @native.clear
665
-
666
- # Durably commits the buffered operations mid-handler.
667
- #
668
- # @return [nil] the erased FFI seam drops the applied/no-op outcome
669
- def commit = @native.commit
670
-
671
- # Discards the buffered uncommitted operations.
672
- #
673
- # @return [nil]
674
- def rollback = @native.rollback
675
-
676
- # Reads the element at `index`, resolving negatives Array-style (mirrors
677
- # +Array#[]+'s read domain, without the indexer). A non-negative index
678
- # reads from the front; `-1` is the back element, `-n` the nth from the end.
679
- # `-1` fast-paths through {#last} (no length read); other negatives resolve
680
- # against the current length (one length read + one element read),
681
- # consistent because the deque has a single writer per attempt.
682
- #
683
- # @param index [Integer] the position (negative counts from the back)
684
- # @return [Object, nil] the element, or `nil` outside the bounds
685
- # @raise [TransientStateError] if `index` is not an Integer
686
- def get(index)
687
- unless index.is_a?(Integer)
688
- raise TransientStateError, "get: index must be an Integer, got #{index.inspect}"
689
- end
690
- index.negative? ? at_negative(index) : @native.get(index)
691
- end
692
-
693
- # Traverses the live elements in index order.
694
- #
695
- # Without a block, returns an {Enumerator} over the native scan. Each step
696
- # fiber-yields; the scan is closed via `ensure` on stop or exception.
697
- #
698
- # @yieldparam element [Object]
699
- # @return [Enumerator, void]
700
- def each(&block) = traverse(:forward, &block)
701
-
702
- # Traverses the live elements in reverse index order.
703
- #
704
- # @yieldparam element [Object]
705
- # @return [Enumerator, void]
706
- def reverse_each(&block) = traverse(:backward, &block)
707
-
708
- # --- idiomatic Array-style conveniences -----------------------------
709
- # Composed from the canonical ops above; bounded reads only (no +to_a+,
710
- # +map+, +sort+, or +Enumerable+ that would materialize the whole deque).
711
- #
712
- # Deliberately NOT provided: +[]+ and +at+. This is a remote deque; +get+
713
- # and +fetch+ accept a single +Integer+ index (negatives resolve from the
714
- # back, Array-style), but wearing +Array+'s +[]+/+at+ would invite a range
715
- # read (+deque[0..2]+) that cannot be honored. Use the explicit {#get}, or
716
- # {#first}/{#last} for the ends.
717
-
718
- # Prepends +value+, returning +self+ for chaining (mirrors +Array#prepend+).
719
- # A wrapper, not an alias: the native write returns +nil+.
720
- #
721
- # @param value [Object]
722
- # @return [self]
723
- def prepend(value)
724
- unshift(value)
725
- self
726
- end
727
-
728
- # Appends +value+, returning +self+ for chaining (mirrors +Array#append+).
729
- # A wrapper, not an alias: the native write returns +nil+.
730
- #
731
- # @param value [Object]
732
- # @return [self]
733
- def append(value)
734
- push(value)
735
- self
736
- end
737
-
738
- # Appends +value+ at the back and returns +self+ for chaining
739
- # (mirrors +Array#<<+).
740
- #
741
- # @param value [Object]
742
- # @return [self]
743
- def <<(value)
744
- push(value)
745
- self
746
- end
747
-
748
- # The front element, or +nil+ when empty (mirrors +Array#first+). An
749
- # endpoint-slot read in one round trip (no length read). Under a TTL an
750
- # expired front slot yields +nil+ even when live interior elements remain —
751
- # a peek never searches inward.
752
- #
753
- # @return [Object, nil]
754
- def first = @native.peek_front
755
-
756
- # The back element, or +nil+ when empty (mirrors +Array#last+). An
757
- # endpoint-slot read in one round trip (no length read); same TTL-hole
758
- # semantics as {#first}.
759
- #
760
- # @return [Object, nil]
761
- def last = @native.peek_back
762
-
763
- # Reads the element at +index+, raising or defaulting when out of range
764
- # (mirrors +Array#fetch+). A +nil+ result is unambiguously "out of range"
765
- # under the null ban. Negatives resolve Array-style like {#get} — +-1+ is
766
- # the back element, +-n+ the nth from the end; a fractional or non-Integer
767
- # index is a caller mistake, rejected {TransientStateError}.
768
- #
769
- # @param index [Integer] the position (negative counts from the back)
770
- # @param default [Object] returned when +index+ is out of range
771
- # @yieldparam index [Integer] called (instead of +default+) when out of range
772
- # @return [Object]
773
- # @raise [IndexError] when out of range and no default or block is given
774
- # @raise [TransientStateError] if +index+ is not an Integer
775
- def fetch(index, *default, &block)
776
- if default.length > 1
777
- raise ArgumentError, "wrong number of arguments (given #{default.length + 1}, expected 1..2)"
778
- end
779
- unless index.is_a?(Integer)
780
- raise TransientStateError, "fetch: index must be an Integer, got #{index.inspect}"
781
- end
782
- warn "warning: block supersedes default value argument" if block && !default.empty?
783
-
784
- value = index.negative? ? at_negative(index) : @native.get(index)
785
- return value unless value.nil?
786
- return block.call(index) if block
787
- return default.first unless default.empty?
788
-
789
- raise IndexError, "index #{index} outside deque bounds"
790
- end
791
-
792
- private
793
-
794
- # Resolves a negative Array-style index against the current length: +-1+
795
- # fast-paths through {#last} (no length read), other negatives read the
796
- # length and index from the front. Returns +nil+ when the index resolves
797
- # before the front (past the far end of the deque).
798
- def at_negative(index)
799
- return @native.peek_back if index == -1
800
-
801
- resolved = @native.len + index
802
- resolved.negative? ? nil : @native.get(resolved)
803
- end
804
-
805
- def traverse(direction)
806
- return enum_for(:traverse, direction) unless block_given?
263
+ class Client
264
+ include State::Reading
807
265
 
808
- scan_each(direction) { |item| yield item }
809
- end
266
+ private :published_value, :published_map, :published_set, :published_deque
810
267
  end
811
268
 
812
269
  # Reopens the native context class to add keyed-state vending.
813
270
  class Context
814
271
  include State::Vending
272
+
273
+ private :value_state, :map_state, :set_state, :deque_state,
274
+ :message_value_state, :message_map_state, :message_deque_state
815
275
  end
816
276
  end
277
+
278
+ require_relative "state/value"
279
+ require_relative "state/map"
280
+ require_relative "state/set"
281
+ require_relative "state/deque"
282
+
283
+ module Prosody
284
+ # How a definition opens its handle and, for a published collection, its reader.
285
+ StateAccess = Data.define(:vend_method, :wrapper, :reader)
286
+ private_constant :StateAccess
287
+ VALUE_ACCESS = StateAccess.new(:value_state, ValueState, [:published_value, PublishedValue])
288
+ MAP_ACCESS = StateAccess.new(:map_state, MapState, [:published_map, PublishedMap])
289
+ SET_ACCESS = StateAccess.new(:set_state, SetState, [:published_set, PublishedSet])
290
+ DEQUE_ACCESS = StateAccess.new(:deque_state, DequeState, [:published_deque, PublishedDeque])
291
+ MESSAGE_VALUE_ACCESS = StateAccess.new(:message_value_state, ValueState, nil)
292
+ MESSAGE_MAP_ACCESS = StateAccess.new(:message_map_state, MapState, nil)
293
+ MESSAGE_DEQUE_ACCESS = StateAccess.new(:message_deque_state, DequeState, nil)
294
+ private_constant :VALUE_ACCESS, :MAP_ACCESS, :SET_ACCESS, :DEQUE_ACCESS,
295
+ :MESSAGE_VALUE_ACCESS, :MESSAGE_MAP_ACCESS, :MESSAGE_DEQUE_ACCESS
296
+ end