prosody 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.cargo/config.toml +3 -0
  3. data/.release-please-manifest.json +1 -1
  4. data/AGENTS.md +395 -0
  5. data/ARCHITECTURE.md +14 -4
  6. data/CHANGELOG.md +28 -0
  7. data/CLAUDE.md +1 -0
  8. data/CONFIGURATION.md +167 -0
  9. data/Cargo.lock +1115 -645
  10. data/Cargo.toml +7 -6
  11. data/README.md +436 -146
  12. data/Rakefile +11 -1
  13. data/examples/keyed_state.rb +70 -0
  14. data/examples/keyed_state.rbs +18 -0
  15. data/examples/keyed_state_windowing.rb +55 -0
  16. data/examples/keyed_state_windowing.rbs +16 -0
  17. data/ext/prosody/Cargo.toml +1 -0
  18. data/ext/prosody/src/admin.rs +1 -5
  19. data/ext/prosody/src/bridge/mod.rs +17 -32
  20. data/ext/prosody/src/client/config.rs +501 -28
  21. data/ext/prosody/src/client/mod.rs +167 -74
  22. data/ext/prosody/src/client/request.rs +132 -0
  23. data/ext/prosody/src/client/support.rs +122 -0
  24. data/ext/prosody/src/handler/context.rs +150 -5
  25. data/ext/prosody/src/handler/message.rs +67 -0
  26. data/ext/prosody/src/handler/mod.rs +115 -85
  27. data/ext/prosody/src/handler/state/mod.rs +488 -0
  28. data/ext/prosody/src/handler/state/registration.rs +104 -0
  29. data/ext/prosody/src/handler/state/scan.rs +218 -0
  30. data/ext/prosody/src/lib.rs +15 -3
  31. data/ext/prosody/src/published.rs +273 -0
  32. data/ext/prosody/src/scheduler/mod.rs +2 -2
  33. data/ext/prosody/src/scheduler/processor.rs +2 -2
  34. data/ext/prosody/src/scheduler/result.rs +7 -4
  35. data/ext/prosody/src/util.rs +86 -5
  36. data/lib/prosody/configuration.rb +71 -11
  37. data/lib/prosody/handler.rb +65 -8
  38. data/lib/prosody/native_stubs.rb +550 -9
  39. data/lib/prosody/request.rb +45 -0
  40. data/lib/prosody/state.rb +816 -0
  41. data/lib/prosody/version.rb +1 -1
  42. data/lib/prosody.rb +6 -0
  43. data/release-please-config.json +4 -0
  44. data/sig/configuration.rbs +70 -11
  45. data/sig/handler.rbs +17 -5
  46. data/sig/processor.rbs +28 -12
  47. data/sig/prosody.rbs +53 -7
  48. data/sig/request.rbs +66 -0
  49. data/sig/sentry.rbs +6 -0
  50. data/sig/state.rbs +390 -0
  51. data/steep_expectations.yml +57 -0
  52. data/typecheck/payload_types.rb +54 -0
  53. data/typecheck/payload_types.rbs +22 -0
  54. data/typecheck_negative/payload_types.rb +20 -0
  55. data/typecheck_negative/payload_types.rbs +9 -0
  56. metadata +32 -9
@@ -7,9 +7,13 @@
7
7
  //! builders.
8
8
 
9
9
  use magnus::{Error, Ruby, Value};
10
+ use prosody::PeerConfiguration;
11
+ use prosody::PeerEndpoint;
10
12
  use prosody::cassandra::config::CassandraConfigurationBuilder;
11
13
  use prosody::consumer::ConsumerConfigurationBuilder;
14
+ use prosody::consumer::KeyedStateConfiguration;
12
15
  use prosody::consumer::SpanRelation;
16
+ use prosody::consumer::kafka_state::{message_deque_state, message_map_state, message_state};
13
17
  use prosody::consumer::middleware::deduplication::DeduplicationConfigurationBuilder;
14
18
  use prosody::consumer::middleware::defer::DeferConfigurationBuilder;
15
19
  use prosody::consumer::middleware::monopolization::MonopolizationConfigurationBuilder;
@@ -19,11 +23,23 @@ use prosody::consumer::middleware::timeout::TimeoutConfigurationBuilder;
19
23
  use prosody::consumer::middleware::topic::FailureTopicConfigurationBuilder;
20
24
  use prosody::high_level::ConsumerBuilders;
21
25
  use prosody::high_level::mode::Mode;
26
+ use prosody::loader::KafkaLoader;
27
+ use prosody::loader::KafkaLoaderConfiguration;
22
28
  use prosody::producer::ProducerConfigurationBuilder;
29
+ use prosody::state::descriptor::{
30
+ DequeDescriptor, MapDescriptor, StateDescriptor, deque_state, map_state, value_state,
31
+ };
32
+ use prosody::state::order_codec::Utf8KeyCodec;
33
+ use prosody::subsystem::SubsystemName;
23
34
  use prosody::telemetry::emitter::TelemetryEmitterConfiguration;
35
+ use prosody::timers::duration::CompactDuration;
36
+ use prosody::{ByteSize, JsonCodec};
24
37
  use serde::{Deserialize, Deserializer};
25
38
  use serde_magnus::deserialize;
26
39
  use serde_untagged::UntaggedEnumVisitor;
40
+ use std::net::SocketAddr;
41
+ use std::num::NonZeroUsize;
42
+ use std::path::PathBuf;
27
43
  use std::time::Duration;
28
44
 
29
45
  /// Configuration structure for the Prosody client that maps Ruby configuration
@@ -104,7 +120,7 @@ pub struct NativeConfiguration {
104
120
  /// List of Cassandra contact nodes (hostnames or IPs)
105
121
  cassandra_nodes: Option<Vec<String>>,
106
122
 
107
- /// Keyspace to use for storing timer data in Cassandra
123
+ /// Keyspace used for persistent Prosody data in Cassandra.
108
124
  cassandra_keyspace: Option<String>,
109
125
 
110
126
  /// Preferred datacenter for Cassandra query routing
@@ -119,8 +135,8 @@ pub struct NativeConfiguration {
119
135
  /// Password for authenticating with Cassandra
120
136
  cassandra_password: Option<String>,
121
137
 
122
- /// Retention period for failed/unprocessed timer data in Cassandra (in
123
- /// seconds)
138
+ /// Retention period for persistent timer and deferral data in Cassandra,
139
+ /// in seconds.
124
140
  cassandra_retention: Option<f32>,
125
141
 
126
142
  /// Timer slab partitioning duration in seconds.
@@ -166,24 +182,24 @@ pub struct NativeConfiguration {
166
182
  /// Maximum delay between deferred retries (in seconds).
167
183
  defer_max_delay: Option<f32>,
168
184
 
169
- /// Failure rate threshold for enabling deferral (0.0 to 1.0).
185
+ /// Failure rate threshold for disabling deferral (0.0 to 1.0).
170
186
  defer_failure_threshold: Option<f64>,
171
187
 
172
188
  /// Sliding window duration (in seconds) for failure rate tracking.
173
189
  defer_failure_window: Option<f32>,
174
190
 
175
- /// Cache size for defer middleware.
176
- defer_cache_size: Option<u32>,
191
+ /// Maximum messages retained by the shared Kafka loader.
192
+ loader_cache_size: Option<u32>,
177
193
 
178
194
  /// Maximum number of deferred store entries kept in the write-through cache
179
195
  /// per Cassandra defer store.
180
196
  defer_store_cache_size: Option<u32>,
181
197
 
182
- /// Timeout for Kafka seek operations (in seconds).
183
- defer_seek_timeout: Option<f32>,
198
+ /// Timeout for Kafka loader seek operations (in seconds).
199
+ loader_seek_timeout: Option<f32>,
184
200
 
185
201
  /// Messages to read sequentially before seeking.
186
- defer_discard_threshold: Option<i64>,
202
+ loader_discard_threshold: Option<i64>,
187
203
 
188
204
  // Timeout configuration
189
205
  /// Fixed timeout duration for handler execution (in seconds).
@@ -202,6 +218,85 @@ pub struct NativeConfiguration {
202
218
 
203
219
  /// Span linking for timer execution spans (`child` or `follows_from`).
204
220
  timer_spans: Option<String>,
221
+
222
+ /// Address for the peer listener.
223
+ peer_bind_address: Option<String>,
224
+
225
+ /// gRPC connect URI that peers use for this client.
226
+ peer_advertised_connect: Option<String>,
227
+
228
+ /// Network name used to identify direct routes.
229
+ peer_network_name: Option<String>,
230
+
231
+ /// Maximum number of peer channels and registrations held in each cache.
232
+ peer_cache_capacity: Option<usize>,
233
+
234
+ /// Duration of each peer registration lease, in seconds.
235
+ peer_registration_ttl: Option<f64>,
236
+
237
+ // Keyed-state configuration
238
+ /// Keyed-state collections to register before subscribe.
239
+ state_collections: Option<Vec<StateCollectionConfig>>,
240
+
241
+ /// Subsystem under which published collections are advertised.
242
+ subsystem: Option<String>,
243
+
244
+ /// Root directory for the local keyed-state cache. Must not be empty.
245
+ state_cache_dir: Option<String>,
246
+
247
+ /// Capacity of the owning keyed-state cache.
248
+ state_owned_cache_size: Option<String>,
249
+
250
+ /// Capacity of the published-state read-through cache.
251
+ state_read_cache_size: Option<String>,
252
+
253
+ /// Default cache policy for published-state reads.
254
+ state_read_cache: Option<ReadCacheConfig>,
255
+
256
+ /// Delay in whole seconds before the keyed-state recovery sweep.
257
+ ///
258
+ /// Crosses as an `f64` so fractional/negative/non-finite values reach the
259
+ /// whole-number guard rather than being silently truncated.
260
+ state_recovery_delay: Option<f64>,
261
+ }
262
+
263
+ /// Declares one keyed-state collection to register before subscribe.
264
+ #[derive(Clone, Debug, Deserialize)]
265
+ struct StateCollectionConfig {
266
+ /// The collection name. Prosody requires it to be non-empty and unique.
267
+ name: String,
268
+
269
+ /// The collection kind: `"value"`, `"map"`, or `"deque"`.
270
+ kind: String,
271
+
272
+ /// The item payload: `"json"` or `"message"`.
273
+ payload: String,
274
+
275
+ /// Optional per-write TTL in whole seconds. Crosses as `f64` so
276
+ /// fractional/negative/non-finite values reach the whole-number guard.
277
+ ttl_seconds: Option<f64>,
278
+
279
+ /// Optional opt-out of transactional staging.
280
+ read_uncommitted: Option<bool>,
281
+
282
+ /// Whether other consumer groups may read this JSON collection.
283
+ published: Option<bool>,
284
+
285
+ /// Optional map-only keyset bound (`0..=4096`). The binding rejects values
286
+ /// that cannot map to an unsigned integer. Prosody enforces the ceiling.
287
+ keyset_limit: Option<f64>,
288
+
289
+ /// Optional deque-only window capacity (`>= 1`). Runtime-only and not
290
+ /// persisted. Crosses as `f64` so fractional/negative/non-finite values
291
+ /// reach the whole-number guard.
292
+ capacity: Option<f64>,
293
+ }
294
+
295
+ #[derive(Clone, Debug, Deserialize)]
296
+ #[serde(untagged)]
297
+ enum ReadCacheConfig {
298
+ Disabled(bool),
299
+ Ttl(f64),
205
300
  }
206
301
 
207
302
  /// Configuration for the health probe port.
@@ -651,22 +746,10 @@ impl<'a> From<&'a NativeConfiguration> for DeferConfigurationBuilder {
651
746
  builder.failure_window(Duration::from_secs_f32(*failure_window));
652
747
  }
653
748
 
654
- if let Some(cache_size) = &config.defer_cache_size {
655
- builder.cache_size(*cache_size as usize);
656
- }
657
-
658
749
  if let Some(store_cache_size) = &config.defer_store_cache_size {
659
750
  builder.store_cache_size(*store_cache_size as usize);
660
751
  }
661
752
 
662
- if let Some(seek_timeout) = &config.defer_seek_timeout {
663
- builder.seek_timeout(Duration::from_secs_f32(*seek_timeout));
664
- }
665
-
666
- if let Some(discard_threshold) = &config.defer_discard_threshold {
667
- builder.discard_threshold(*discard_threshold);
668
- }
669
-
670
753
  builder
671
754
  }
672
755
  }
@@ -696,25 +779,41 @@ impl<'a> From<&'a NativeConfiguration> for TimeoutConfigurationBuilder {
696
779
  }
697
780
  }
698
781
 
699
- impl<'a> From<&'a NativeConfiguration> for DeduplicationConfigurationBuilder {
700
- /// Converts a `NativeConfiguration` reference into a
782
+ impl<'a> TryFrom<&'a NativeConfiguration> for DeduplicationConfigurationBuilder {
783
+ type Error = String;
784
+
785
+ /// Attempts to convert a `NativeConfiguration` reference into a
701
786
  /// `DeduplicationConfigurationBuilder`.
702
787
  ///
703
788
  /// This takes the relevant deduplication settings from the configuration
704
789
  /// and sets them on a new `DeduplicationConfigurationBuilder` instance.
705
790
  ///
791
+ /// Consumer deduplication is mandatory in the core (it is the keyed-state
792
+ /// commit oracle), so `cache_capacity` is `NonZeroUsize` and a zero
793
+ /// capacity is unrepresentable rather than a silent "disable". An explicit
794
+ /// `idempotence_cache_size` of `0` is therefore rejected here rather than
795
+ /// silently defaulting; this mirrors the sibling `prosody-js` binding and
796
+ /// the core's own rejection of `PROSODY_IDEMPOTENCE_CACHE_SIZE=0`.
797
+ ///
706
798
  /// # Arguments
707
799
  ///
708
800
  /// * `config` - The configuration to convert
709
801
  ///
710
802
  /// # Returns
711
803
  ///
712
- /// A configured `DeduplicationConfigurationBuilder`
713
- fn from(config: &'a NativeConfiguration) -> Self {
804
+ /// A configured `DeduplicationConfigurationBuilder` if successful
805
+ ///
806
+ /// # Errors
807
+ ///
808
+ /// Returns a `String` error if `idempotence_cache_size` is explicitly set
809
+ /// to `0`.
810
+ fn try_from(config: &'a NativeConfiguration) -> Result<Self, Self::Error> {
714
811
  let mut builder = Self::default();
715
812
 
716
813
  if let Some(cache_capacity) = &config.idempotence_cache_size {
717
- builder.cache_capacity(*cache_capacity as usize);
814
+ let cache_capacity = NonZeroUsize::new(*cache_capacity as usize)
815
+ .ok_or_else(|| "idempotence_cache_size must be greater than 0".to_owned())?;
816
+ builder.cache_capacity(cache_capacity);
718
817
  }
719
818
 
720
819
  if let Some(version) = &config.idempotence_version {
@@ -728,7 +827,7 @@ impl<'a> From<&'a NativeConfiguration> for DeduplicationConfigurationBuilder {
728
827
  builder.ttl(Duration::from_secs_f64(*ttl));
729
828
  }
730
829
 
731
- builder
830
+ Ok(builder)
732
831
  }
733
832
  }
734
833
 
@@ -770,6 +869,354 @@ impl<'a> TryFrom<&'a NativeConfiguration> for TelemetryEmitterConfiguration {
770
869
  }
771
870
  }
772
871
 
872
+ /// The kind of a keyed-state collection.
873
+ enum CollectionKind {
874
+ /// A single-value collection.
875
+ Value,
876
+ /// A `String`-keyed ordered map.
877
+ Map,
878
+ /// A deque.
879
+ Deque,
880
+ }
881
+
882
+ /// The item payload of a keyed-state collection.
883
+ enum CollectionPayload {
884
+ /// JSON values.
885
+ Json,
886
+ /// The full Kafka message the handler received.
887
+ Message,
888
+ }
889
+
890
+ /// Parses a collection-kind token.
891
+ ///
892
+ /// # Errors
893
+ ///
894
+ /// Returns a permanent-category error naming the field if the token is not
895
+ /// `"value"`, `"map"`, or `"deque"`.
896
+ fn parse_kind(index: usize, kind: &str) -> Result<CollectionKind, String> {
897
+ match kind {
898
+ "value" => Ok(CollectionKind::Value),
899
+ "map" => Ok(CollectionKind::Map),
900
+ "deque" => Ok(CollectionKind::Deque),
901
+ other => Err(format!(
902
+ "state_collections[{index}].kind: expected \"value\", \"map\", or \"deque\", got \
903
+ {other:?}"
904
+ )),
905
+ }
906
+ }
907
+
908
+ /// Parses a collection-payload token.
909
+ ///
910
+ /// # Errors
911
+ ///
912
+ /// Returns a permanent-category error naming the field if the token is not
913
+ /// `"json"` or `"message"`.
914
+ fn parse_payload(index: usize, payload: &str) -> Result<CollectionPayload, String> {
915
+ match payload {
916
+ "json" => Ok(CollectionPayload::Json),
917
+ "message" => Ok(CollectionPayload::Message),
918
+ other => Err(format!(
919
+ "state_collections[{index}].payload: expected \"json\" or \"message\", got {other:?}"
920
+ )),
921
+ }
922
+ }
923
+
924
+ /// Validates a numeric field as a whole number within `min..=max`.
925
+ ///
926
+ /// The field arrives as an `f64` (the raw Ruby number, un-coerced) so that
927
+ /// fractional, negative, and non-finite values reach this guard instead of
928
+ /// being silently truncated or wrapped by an earlier integer conversion.
929
+ ///
930
+ /// # Errors
931
+ ///
932
+ /// Returns a permanent-category error naming the field if the value is not a
933
+ /// whole number in the inclusive range.
934
+ fn whole_number_field(value: f64, field: &str, min: u32, max: u32) -> Result<u32, String> {
935
+ if value.is_finite()
936
+ && value.fract() == 0.0
937
+ && value >= f64::from(min)
938
+ && value <= f64::from(max)
939
+ {
940
+ Ok(value as u32)
941
+ } else {
942
+ Err(format!("{field}: must be a whole number in {min}..={max}"))
943
+ }
944
+ }
945
+
946
+ fn build_peer_config(config: &NativeConfiguration) -> Result<PeerConfiguration, String> {
947
+ let mut builder = PeerConfiguration::builder();
948
+ if let Some(value) = &config.peer_bind_address {
949
+ builder.bind_address(
950
+ value
951
+ .parse::<SocketAddr>()
952
+ .map_err(|error| format!("peer_bind_address: {error}"))?,
953
+ );
954
+ }
955
+ if let Some(value) = &config.peer_advertised_connect {
956
+ builder.advertised_connect(
957
+ PeerEndpoint::try_from(value.clone())
958
+ .map_err(|error| format!("peer_advertised_connect: {error}"))?,
959
+ );
960
+ }
961
+ if let Some(value) = &config.peer_network_name {
962
+ builder.network_name(value.clone());
963
+ }
964
+ if let Some(value) = config.peer_cache_capacity {
965
+ builder.peer_cache_capacity(value);
966
+ }
967
+ if let Some(value) = config.peer_registration_ttl {
968
+ builder.registration_ttl(
969
+ Duration::try_from_secs_f64(value)
970
+ .map_err(|_| "peer_registration_ttl: must be a valid duration".to_owned())?,
971
+ );
972
+ }
973
+ builder.build().map_err(|error| error.to_string())
974
+ }
975
+
976
+ /// Applies the shared descriptor options (TTL, commit mode) fluently.
977
+ fn with_def<D: StateDescriptor>(
978
+ descriptor: D,
979
+ ttl_seconds: Option<u32>,
980
+ read_uncommitted: Option<bool>,
981
+ published: Option<bool>,
982
+ ) -> D {
983
+ let mut descriptor = descriptor;
984
+ if let Some(ttl) = ttl_seconds {
985
+ descriptor = descriptor.ttl(CompactDuration::new(ttl));
986
+ }
987
+ if read_uncommitted == Some(true) {
988
+ descriptor = descriptor.read_uncommitted();
989
+ }
990
+ if let Some(published) = published {
991
+ descriptor = descriptor.published(published);
992
+ }
993
+ descriptor
994
+ }
995
+
996
+ /// Applies the map-only keyset bound when configured.
997
+ fn with_keyset<KC, V>(
998
+ descriptor: MapDescriptor<KC, V>,
999
+ keyset_limit: Option<u32>,
1000
+ ) -> MapDescriptor<KC, V> {
1001
+ match keyset_limit {
1002
+ Some(limit) => descriptor.keyset_limit(limit as usize),
1003
+ None => descriptor,
1004
+ }
1005
+ }
1006
+
1007
+ /// Applies the deque-only window capacity when configured.
1008
+ fn with_capacity<T>(
1009
+ descriptor: DequeDescriptor<T>,
1010
+ capacity: Option<NonZeroUsize>,
1011
+ ) -> DequeDescriptor<T> {
1012
+ match capacity {
1013
+ Some(cap) => descriptor.capacity(cap),
1014
+ None => descriptor,
1015
+ }
1016
+ }
1017
+
1018
+ /// Maps one collection into its descriptor over the closed 3×2 (kind ×
1019
+ /// payload) matrix.
1020
+ ///
1021
+ /// # Errors
1022
+ ///
1023
+ /// Returns a permanent-category error when a host value cannot be mapped into
1024
+ /// a Prosody type.
1025
+ fn register_state_collection(
1026
+ keyed: &mut KeyedStateConfiguration,
1027
+ index: usize,
1028
+ collection: &StateCollectionConfig,
1029
+ ) -> Result<(), String> {
1030
+ let kind = parse_kind(index, &collection.kind)?;
1031
+ let payload = parse_payload(index, &collection.payload)?;
1032
+
1033
+ let ttl_seconds = match collection.ttl_seconds {
1034
+ Some(value) => Some(whole_number_field(
1035
+ value,
1036
+ &format!("state_collections[{index}].ttl_seconds"),
1037
+ 0,
1038
+ u32::MAX,
1039
+ )?),
1040
+ None => None,
1041
+ };
1042
+
1043
+ let keyset_limit = keyset_limit(collection.keyset_limit, &kind, index)?;
1044
+ let capacity = capacity(collection.capacity, &kind, index)?;
1045
+
1046
+ let read_uncommitted = collection.read_uncommitted;
1047
+ let name = collection.name.as_str();
1048
+ match (kind, payload) {
1049
+ (CollectionKind::Value, CollectionPayload::Json) => {
1050
+ let _ = keyed.register(with_def(
1051
+ value_state::<JsonCodec>(name),
1052
+ ttl_seconds,
1053
+ read_uncommitted,
1054
+ collection.published,
1055
+ ));
1056
+ }
1057
+ (CollectionKind::Map, CollectionPayload::Json) => {
1058
+ let descriptor = with_def(
1059
+ map_state::<Utf8KeyCodec, JsonCodec>(name),
1060
+ ttl_seconds,
1061
+ read_uncommitted,
1062
+ collection.published,
1063
+ );
1064
+ let _ = keyed.register(with_keyset(descriptor, keyset_limit));
1065
+ }
1066
+ (CollectionKind::Deque, CollectionPayload::Json) => {
1067
+ let descriptor = with_def(
1068
+ deque_state::<JsonCodec>(name),
1069
+ ttl_seconds,
1070
+ read_uncommitted,
1071
+ collection.published,
1072
+ );
1073
+ let _ = keyed.register(with_capacity(descriptor, capacity));
1074
+ }
1075
+ (CollectionKind::Value, CollectionPayload::Message) => {
1076
+ let _ = keyed.register(with_def(
1077
+ message_state::<KafkaLoader<JsonCodec>>(name),
1078
+ ttl_seconds,
1079
+ read_uncommitted,
1080
+ collection.published,
1081
+ ));
1082
+ }
1083
+ (CollectionKind::Map, CollectionPayload::Message) => {
1084
+ let descriptor = with_def(
1085
+ message_map_state::<Utf8KeyCodec, KafkaLoader<JsonCodec>>(name),
1086
+ ttl_seconds,
1087
+ read_uncommitted,
1088
+ collection.published,
1089
+ );
1090
+ let _ = keyed.register(with_keyset(descriptor, keyset_limit));
1091
+ }
1092
+ (CollectionKind::Deque, CollectionPayload::Message) => {
1093
+ let descriptor = with_def(
1094
+ message_deque_state::<KafkaLoader<JsonCodec>>(name),
1095
+ ttl_seconds,
1096
+ read_uncommitted,
1097
+ collection.published,
1098
+ );
1099
+ let _ = keyed.register(with_capacity(descriptor, capacity));
1100
+ }
1101
+ }
1102
+
1103
+ Ok(())
1104
+ }
1105
+
1106
+ fn keyset_limit(
1107
+ value: Option<f64>,
1108
+ kind: &CollectionKind,
1109
+ index: usize,
1110
+ ) -> Result<Option<u32>, String> {
1111
+ let Some(value) = value else {
1112
+ return Ok(None);
1113
+ };
1114
+ if !matches!(kind, CollectionKind::Map) {
1115
+ return Err(format!(
1116
+ "state_collections[{index}].keyset_limit: only valid for map collections"
1117
+ ));
1118
+ }
1119
+ whole_number_field(
1120
+ value,
1121
+ &format!("state_collections[{index}].keyset_limit"),
1122
+ 0,
1123
+ u32::MAX,
1124
+ )
1125
+ .map(Some)
1126
+ }
1127
+
1128
+ fn capacity(
1129
+ value: Option<f64>,
1130
+ kind: &CollectionKind,
1131
+ index: usize,
1132
+ ) -> Result<Option<NonZeroUsize>, String> {
1133
+ let Some(value) = value else {
1134
+ return Ok(None);
1135
+ };
1136
+ if !matches!(kind, CollectionKind::Deque) {
1137
+ return Err(format!(
1138
+ "state_collections[{index}].capacity: only valid for deque collections"
1139
+ ));
1140
+ }
1141
+ let value = whole_number_field(
1142
+ value,
1143
+ &format!("state_collections[{index}].capacity"),
1144
+ 1,
1145
+ u32::MAX,
1146
+ )?;
1147
+ Ok(NonZeroUsize::new(value as usize))
1148
+ }
1149
+
1150
+ /// Builds the `KeyedStateConfiguration` by mapping each declared collection.
1151
+ /// The normal Prosody construction path validates the result.
1152
+ ///
1153
+ /// # Errors
1154
+ ///
1155
+ /// Returns an error if a host value cannot be mapped.
1156
+ fn build_keyed_state_config(
1157
+ config: &NativeConfiguration,
1158
+ ) -> Result<KeyedStateConfiguration, String> {
1159
+ let mut builder = KeyedStateConfiguration::builder();
1160
+
1161
+ if let Some(dir) = &config.state_cache_dir {
1162
+ builder.cache_dir(PathBuf::from(dir));
1163
+ }
1164
+
1165
+ if let Some(seconds) = config.state_recovery_delay {
1166
+ let seconds = whole_number_field(seconds, "state_recovery_delay", 0, u32::MAX)?;
1167
+ builder.recovery_delay(CompactDuration::new(seconds));
1168
+ }
1169
+
1170
+ if let Some(size) = &config.state_owned_cache_size {
1171
+ let size = size
1172
+ .parse::<ByteSize>()
1173
+ .map_err(|error| format!("state_owned_cache_size: {error}"))?;
1174
+ builder.owned_cache_size(Some(size));
1175
+ }
1176
+
1177
+ if let Some(size) = &config.state_read_cache_size {
1178
+ let size = size
1179
+ .parse::<ByteSize>()
1180
+ .map_err(|error| format!("state_read_cache_size: {error}"))?;
1181
+ builder.read_cache_size(Some(size));
1182
+ }
1183
+
1184
+ if let Some(cache) = &config.state_read_cache {
1185
+ match cache {
1186
+ ReadCacheConfig::Disabled(false) => {
1187
+ builder.read_cache_ttl(None);
1188
+ }
1189
+ ReadCacheConfig::Disabled(true) => {
1190
+ return Err(
1191
+ "state_read_cache: true is ambiguous; use a duration or false".to_owned(),
1192
+ );
1193
+ }
1194
+ ReadCacheConfig::Ttl(seconds) => {
1195
+ let ttl = Duration::try_from_secs_f64(*seconds).map_err(|_| {
1196
+ "state_read_cache: duration must be finite and non-negative".to_owned()
1197
+ })?;
1198
+ builder.read_cache_ttl(Some(ttl));
1199
+ }
1200
+ }
1201
+ }
1202
+
1203
+ if let Some(subsystem) = &config.subsystem {
1204
+ builder.subsystem(Some(
1205
+ SubsystemName::try_new(subsystem).map_err(|error| error.to_string())?,
1206
+ ));
1207
+ }
1208
+
1209
+ let mut keyed = builder.build().map_err(|error| error.to_string())?;
1210
+
1211
+ if let Some(collections) = &config.state_collections {
1212
+ for (index, collection) in collections.iter().enumerate() {
1213
+ register_state_collection(&mut keyed, index, collection)?;
1214
+ }
1215
+ }
1216
+
1217
+ Ok(keyed)
1218
+ }
1219
+
773
1220
  impl<'a> TryFrom<&'a NativeConfiguration> for ConsumerBuilders {
774
1221
  type Error = String;
775
1222
 
@@ -795,6 +1242,8 @@ impl<'a> TryFrom<&'a NativeConfiguration> for ConsumerBuilders {
795
1242
  /// environment variable contains an unparseable value).
796
1243
  /// - `message_spans` or `timer_spans` contains an unrecognized value
797
1244
  /// (expected `"child"` or `"follows_from"`).
1245
+ /// - The Kafka loader configuration cannot be built (e.g. a tuning value
1246
+ /// fails validation).
798
1247
  fn try_from(config: &'a NativeConfiguration) -> Result<Self, Self::Error> {
799
1248
  let mut consumer: ConsumerConfigurationBuilder = config.into();
800
1249
 
@@ -812,6 +1261,28 @@ impl<'a> TryFrom<&'a NativeConfiguration> for ConsumerBuilders {
812
1261
  consumer.timer_spans(relation);
813
1262
  }
814
1263
 
1264
+ // Route the shared Kafka message loader settings onto the consumer.
1265
+ if config.loader_cache_size.is_some()
1266
+ || config.loader_seek_timeout.is_some()
1267
+ || config.loader_discard_threshold.is_some()
1268
+ {
1269
+ let mut loader = KafkaLoaderConfiguration::builder();
1270
+
1271
+ if let Some(cache_size) = &config.loader_cache_size {
1272
+ loader.cache_size(*cache_size as usize);
1273
+ }
1274
+
1275
+ if let Some(seek_timeout) = &config.loader_seek_timeout {
1276
+ loader.seek_timeout(Duration::from_secs_f32(*seek_timeout));
1277
+ }
1278
+
1279
+ if let Some(discard_threshold) = &config.loader_discard_threshold {
1280
+ loader.discard_threshold(*discard_threshold);
1281
+ }
1282
+
1283
+ consumer.loader(loader.build().map_err(|e| e.to_string())?);
1284
+ }
1285
+
815
1286
  Ok(Self {
816
1287
  consumer,
817
1288
  retry: config.into(),
@@ -820,8 +1291,10 @@ impl<'a> TryFrom<&'a NativeConfiguration> for ConsumerBuilders {
820
1291
  monopolization: config.into(),
821
1292
  defer: config.into(),
822
1293
  timeout: config.into(),
823
- dedup: config.into(),
1294
+ dedup: config.try_into()?,
824
1295
  emitter: config.try_into()?,
1296
+ keyed_state: build_keyed_state_config(config)?,
1297
+ peer: build_peer_config(config)?,
825
1298
  })
826
1299
  }
827
1300
  }