feldera_types/config/dev_tweaks.rs
1use std::collections::BTreeMap;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use utoipa::ToSchema;
6
7/// Optional settings for tweaking Feldera internals.
8///
9/// These settings reflect experiments that may come and go and change from
10/// version to version. Users should not consider them to be stable.
11#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ToSchema)]
12#[serde(default)]
13pub struct DevTweaks {
14 /// Buffer-cache implementation to use for storage reads.
15 ///
16 /// The default is `s3_fifo`.
17 #[serde(skip_serializing_if = "Option::is_none")]
18 pub buffer_cache_strategy: Option<BufferCacheStrategy>,
19
20 /// Override the number of buckets/shards used by sharded buffer caches.
21 ///
22 /// This only applies when `buffer_cache_strategy = "s3_fifo"`. Values are
23 /// rounded up to the next power of two because the current implementation
24 /// shards by `hash(key) & (n - 1)`.
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub buffer_max_buckets: Option<usize>,
27
28 /// How S3-FIFO caches are assigned to foreground/background workers.
29 ///
30 /// This only applies when `buffer_cache_strategy = "s3_fifo"`. The
31 /// default is `shared_per_worker_pair`; LRU always uses `per_thread`.
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub buffer_cache_allocation_strategy: Option<BufferCacheAllocationStrategy>,
34
35 /// Target number of cached bytes retained in each `FBuf` slab size class.
36 ///
37 /// The default is 16 MiB.
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub fbuf_slab_bytes_per_class: Option<usize>,
40
41 /// Whether to asynchronously fetch keys needed for the join operator from
42 /// storage. Asynchronous fetching should be faster for high-latency
43 /// storage, such as object storage, but it could use excessive amounts of
44 /// memory if the number of keys fetched is very large.
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub fetch_join: Option<bool>,
47
48 /// Whether to asynchronously fetch keys needed for the distinct operator
49 /// from storage. Asynchronous fetching should be faster for high-latency
50 /// storage, such as object storage, but it could use excessive amounts of
51 /// memory if the number of keys fetched is very large.
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub fetch_distinct: Option<bool>,
54
55 /// Which merger to use.
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub merger: Option<MergerType>,
58
59 /// If set, the maximum amount of storage, in MiB, for the POSIX backend to
60 /// allow to be in use before failing all writes with `StorageFull`. This
61 /// is useful for testing on top of storage that does not implement its own
62 /// quota mechanism.
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub storage_mb_max: Option<u64>,
65
66 /// Attempt to print a stack trace on stack overflow.
67 ///
68 /// To be used for debugging only; do not enable in production.
69 // NOTE: this flag is handled manually in `adapters/src/server.rs` before
70 // parsing DevTweaks. If the name or type of this field changes, make sure to
71 // adjust `server.rs` accordingly.
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub stack_overflow_backtrace: Option<bool>,
74
75 /// Controls the maximal number of records output by splitter operators
76 /// (joins, distinct, aggregation, rolling window and group operators) at
77 /// each step.
78 ///
79 /// The default value is 10,000 records.
80 // TODO: It would be better if the value were denominated in bytes rather
81 // than records, and if it were configurable per-operator.
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub splitter_chunk_size_records: Option<u64>,
84
85 /// Enable adaptive joins.
86 ///
87 /// Adaptive joins dynamically change their partitioning policy to avoid skew.
88 ///
89 /// Adaptive joins are disabled by default.
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub adaptive_joins: Option<bool>,
92
93 /// Evict eagerly from buffer caches as files get deleted.
94 ///
95 /// This is an optimization that drops files from
96 /// the cache as soon as they are deleted.
97 ///
98 /// It has unknown (no?) performance benefits from what I can tell.
99 ///
100 /// Historically it made sense to do this for two reasons:
101 /// a) we know with 100% guarantee that the file won't ever be
102 /// read again.
103 /// b) we could do this in O(logn) time with the LRU cache.
104 /// This is no longer true for s3-fifo where it is O(n).
105 ///
106 /// If the eviction is expensive, (many small objects in the cache)
107 /// this can cause a regression.
108 ///
109 /// New default disables this behavior by making it false.
110 ///
111 /// If this doesn't cause regression we will remove this option
112 /// in the future.
113 #[serde(skip_serializing_if = "Option::is_none")]
114 pub eager_evict: Option<bool>,
115
116 /// The minimum relative improvement threshold for the join balancer.
117 ///
118 /// The join balancer is a component that dynamically chooses an optimal
119 /// partitioning policy for adaptive join operators. This parameter
120 /// prevents the join balancer from making changes to the partitioning
121 /// policy if the improvement is not significant, since the overhead of such
122 /// rebalancing, especially when performed frequently, can exceed the
123 /// benefits.
124 ///
125 /// A rebalancing is considered significant if the relative estimated
126 /// improvement across the collections whose partitioning policy the
127 /// rebalancing changes is at least this threshold. Collections that keep
128 /// their policy are excluded, since they cost the same either way.
129 ///
130 /// A rebalancing is applied if both this threshold and
131 /// `balancer_min_absolute_improvement_threshold` are met.
132 ///
133 /// The default value is 1.2.
134 #[serde(skip_serializing_if = "Option::is_none")]
135 #[serde(default, deserialize_with = "crate::serde_via_value::deserialize")]
136 pub balancer_min_relative_improvement_threshold: Option<f64>,
137
138 /// The minimum absolute improvement threshold for the balancer.
139 ///
140 /// The join balancer is a component that dynamically chooses an optimal
141 /// partitioning policy for adaptive join operators. This parameter
142 /// prevents the join balancer from making changes to the partitioning
143 /// policy if the improvement is not significant, since the overhead of such
144 /// rebalancing, especially when performed frequently, can exceed the
145 /// benefits.
146 ///
147 /// A rebalancing is considered significant if the absolute estimated
148 /// improvement across the collections whose partitioning policy the
149 /// rebalancing changes is at least this threshold. The cost model used by the
150 /// balancer is based on the number of records in the largest partition of a
151 /// collection.
152 ///
153 /// A rebalancing is applied if both this threshold and
154 /// `balancer_min_relative_improvement_threshold` are met.
155 ///
156 /// The default value is 10,000.
157 #[serde(skip_serializing_if = "Option::is_none")]
158 pub balancer_min_absolute_improvement_threshold: Option<u64>,
159
160 /// Factor that discourages the use of the Balance policy in a perfectly balanced collection.
161 ///
162 /// Assuming a perfectly balanced key distribution, the Balance policy is slightly less efficient than Shard,
163 /// since it requires computing the hash of the entire key/value pair. This factor discourages the use of this policy
164 /// if the skew is `<balancer_balance_tax`.
165 ///
166 /// The default value is 1.1.
167 #[serde(skip_serializing_if = "Option::is_none")]
168 #[serde(default, deserialize_with = "crate::serde_via_value::deserialize")]
169 pub balancer_balance_tax: Option<f64>,
170
171 /// The balancer threshold for checking for an improved partitioning policy for a stream.
172 ///
173 /// Finding a good partitioning policy for a circuit involves solving an optimization problem,
174 /// which can be relatively expensive. Instead of doing this on every step, the balancer only
175 /// checks for an improved partitioning policy if the key distribution of a stream has changed
176 /// significantly since the current solution was computed. Specifically, it only kicks in when
177 /// the size of at least one shard of at least one stream in the cluster has changed by more than
178 /// this threshold.
179 ///
180 /// The default value is 0.1.
181 #[serde(skip_serializing_if = "Option::is_none")]
182 #[serde(default, deserialize_with = "crate::serde_via_value::deserialize")]
183 pub balancer_key_distribution_refresh_threshold: Option<f64>,
184
185 /// False-positive rate for Bloom filters on batches on storage.
186 ///
187 /// Deprecated: use `storage.bloom_false_positive_rate` instead.
188 #[serde(skip_serializing_if = "Option::is_none")]
189 #[serde(default, deserialize_with = "crate::serde_via_value::deserialize")]
190 pub bloom_false_positive_rate: Option<f64>,
191
192 /// Whether file-backed batches may use roaring membership filters when the
193 /// key type supports them.
194 #[serde(skip_serializing_if = "Option::is_none")]
195 pub enable_roaring: Option<bool>,
196
197 /// Maximum batch size in records for level 0 merges.
198 #[serde(skip_serializing_if = "Option::is_none")]
199 pub max_level0_batch_size_records: Option<u16>,
200
201 /// The number of merger threads.
202 ///
203 /// The default value is equal to the number of worker threads.
204 #[serde(skip_serializing_if = "Option::is_none")]
205 pub merger_threads: Option<u16>,
206
207 /// Additional bias the merger assigns to records with negative weights
208 /// (retractions) to promote them to higher levels of the LSM tree sooner.
209 ///
210 /// Reasonable values for this parameter are in the range [0, 10].
211 ///
212 /// The default value is 0, which means that retractions are not given
213 /// any additional bias.
214 #[serde(skip_serializing_if = "Option::is_none")]
215 pub negative_weight_multiplier: Option<u16>,
216
217 /// Don't automatically start a transaction for every step.
218 #[serde(skip_serializing_if = "Option::is_none")]
219 pub disable_auto_transaction: Option<bool>,
220
221 /// Override the timestamp returned by SQL `NOW()` at pipeline start.
222 ///
223 /// When set, the clock connector anchors `NOW()` to this RFC 3339
224 /// timestamp the first time the pipeline starts and advances at
225 /// wall-clock cadence from there:
226 /// `NOW() = now_offset + (wall_clock - wall_clock_at_start)`.
227 ///
228 /// Any RFC 3339 timestamp parseable by `chrono::DateTime<Utc>` is
229 /// accepted (years `0001` through `9999`), in the past or future
230 /// relative to wall clock.
231 ///
232 /// This is a testing knob for queries that depend on `NOW()`.
233 ///
234 /// On resume the clock continues from the last journaled `NOW()`;
235 /// `now_offset`'s value is honored only on a fresh start:
236 ///
237 /// | Initial run | Resume from checkpoint | Post-replay `NOW()` |
238 /// |---|---|---|
239 /// | no offset | no offset | wall clock (unchanged) |
240 /// | offset | offset | wall-clock pace from the last journaled value; the new offset value is ignored |
241 /// | offset | no offset | jumps to wall clock (explicit opt-out of the anchor) |
242 /// | no offset | offset | wall-clock pace from the last journaled value; the new offset value is ignored |
243 #[serde(skip_serializing_if = "Option::is_none")]
244 pub now_offset: Option<DateTime<Utc>>,
245
246 /// Drive `NOW()` from an external HTTP endpoint instead of wall clock.
247 ///
248 /// When `true`, the clock connector emits one initial tick (using
249 /// `now_offset` if set, otherwise wall clock) and then holds that
250 /// value. Subsequent calls to `POST /clock/advance` move `NOW()`
251 /// forward by the requested delta. Negative deltas are rejected;
252 /// the clock is forward-only.
253 #[serde(skip_serializing_if = "Option::is_none")]
254 pub now_http_driven: Option<bool>,
255
256 /// Enable streaming exchange.
257 #[serde(skip_serializing_if = "Option::is_none")]
258 pub streaming_exchange: Option<bool>,
259
260 /// Maximum number of bytes of queued but unacknowledged exchange messages
261 /// per pair of remote host and message type.
262 ///
263 /// A sender that pushes past this budget waits for the receiver to
264 /// acknowledge earlier messages before it queues more. There are three
265 /// message types, so a host buffers up to three times this many bytes for
266 /// each of the other hosts, plus any single message that exceeds the
267 /// budget on its own.
268 ///
269 /// The default is 10,000,000 bytes.
270 #[serde(skip_serializing_if = "Option::is_none")]
271 pub exchange_channel_capacity_bytes: Option<usize>,
272
273 /// Optimize input operators during transaction commit.
274 #[serde(skip_serializing_if = "Option::is_none")]
275 pub optimize_input_during_commit: Option<bool>,
276
277 /// Options not understood by this particular version.
278 ///
279 /// This allows the pipeline manager to take options that a custom or old
280 /// runtime version accepts.
281 #[serde(flatten)]
282 pub other_options: BTreeMap<String, serde_json::Value>,
283}
284
285impl DevTweaks {
286 pub fn buffer_cache_strategy(&self) -> BufferCacheStrategy {
287 self.buffer_cache_strategy.unwrap_or_default()
288 }
289 pub fn buffer_cache_allocation_strategy(&self) -> BufferCacheAllocationStrategy {
290 self.buffer_cache_allocation_strategy.unwrap_or_default()
291 }
292 pub fn effective_buffer_cache_allocation_strategy(&self) -> BufferCacheAllocationStrategy {
293 match self.buffer_cache_strategy() {
294 BufferCacheStrategy::S3Fifo => self.buffer_cache_allocation_strategy(),
295 BufferCacheStrategy::Lru => BufferCacheAllocationStrategy::PerThread,
296 }
297 }
298 pub fn fetch_join(&self) -> bool {
299 self.fetch_join.unwrap_or(false)
300 }
301 pub fn fetch_distinct(&self) -> bool {
302 self.fetch_distinct.unwrap_or(false)
303 }
304 pub fn merger(&self) -> MergerType {
305 self.merger.unwrap_or_default()
306 }
307 pub fn stack_overflow_backtrace(&self) -> bool {
308 self.stack_overflow_backtrace.unwrap_or(false)
309 }
310 pub fn splitter_chunk_size_records(&self) -> u64 {
311 self.splitter_chunk_size_records.unwrap_or(10_000)
312 }
313 pub fn adaptive_joins(&self) -> bool {
314 self.adaptive_joins.unwrap_or(false)
315 }
316 pub fn balancer_min_relative_improvement_threshold(&self) -> f64 {
317 self.balancer_min_relative_improvement_threshold
318 .unwrap_or(1.2)
319 }
320 pub fn balancer_min_absolute_improvement_threshold(&self) -> u64 {
321 self.balancer_min_absolute_improvement_threshold
322 .unwrap_or(10_000)
323 }
324 pub fn balancer_balance_tax(&self) -> f64 {
325 self.balancer_balance_tax.unwrap_or(1.1)
326 }
327 pub fn balancer_key_distribution_refresh_threshold(&self) -> f64 {
328 self.balancer_key_distribution_refresh_threshold
329 .unwrap_or(0.1)
330 }
331 pub fn bloom_false_positive_rate(&self) -> f64 {
332 self.bloom_false_positive_rate.unwrap_or(0.0001)
333 }
334 pub fn enable_roaring(&self) -> bool {
335 // Roaring is enabled by default, but `enable_roaring = false` remains
336 // available as a kill switch while the feature is still being tuned.
337 self.enable_roaring.unwrap_or(true)
338 }
339 pub fn negative_weight_multiplier(&self) -> u16 {
340 self.negative_weight_multiplier.unwrap_or(0)
341 }
342
343 pub fn disable_auto_transaction(&self) -> bool {
344 self.disable_auto_transaction.unwrap_or(false)
345 }
346
347 /// Configured `now_offset` as milliseconds since the Unix epoch,
348 /// or `None` if no override is set.
349 pub fn now_offset_ms(&self) -> Option<i64> {
350 self.now_offset.map(|target| target.timestamp_millis())
351 }
352
353 pub fn now_http_driven(&self) -> bool {
354 self.now_http_driven.unwrap_or(false)
355 }
356
357 pub fn streaming_exchange(&self) -> bool {
358 self.streaming_exchange.unwrap_or(true)
359 }
360
361 pub fn exchange_channel_capacity_bytes(&self) -> usize {
362 self.exchange_channel_capacity_bytes.unwrap_or(10_000_000)
363 }
364
365 pub fn optimize_input_during_commit(&self) -> bool {
366 self.optimize_input_during_commit.unwrap_or(true)
367 }
368}
369
370/// Selects which eviction strategy backs a cache instance.
371#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
372#[serde(rename_all = "snake_case")]
373pub enum BufferCacheStrategy {
374 /// Use the sharded S3-FIFO cache backed by `quick_cache`.
375 #[default]
376 S3Fifo,
377
378 /// Use the mutex-protected weighted LRU cache.
379 Lru,
380}
381
382/// Controls how caches are shared across a foreground/background worker pair.
383#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
384#[serde(rename_all = "snake_case")]
385pub enum BufferCacheAllocationStrategy {
386 /// Share one cache across a foreground/background worker pair.
387 #[default]
388 SharedPerWorkerPair,
389
390 /// Create a separate cache for each foreground/background thread.
391 PerThread,
392
393 /// Share one cache across all foreground/background threads.
394 Global,
395}
396
397/// Which merger to use.
398#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
399#[serde(rename_all = "snake_case")]
400pub enum MergerType {
401 /// Newer merger, which should be faster for high-latency storage, such as
402 /// object storage, but it likely needs tuning.
403 PushMerger,
404
405 /// The old standby, with known performance.
406 #[default]
407 ListMerger,
408}
409
410#[cfg(test)]
411mod tests {
412 use serde_json::json;
413
414 use crate::config::{PipelineConfig, RuntimeConfig};
415
416 use super::*;
417
418 /// Regression test: `Option<f64>` fields inside `DevTweaks` must
419 /// survive a JSON-string round-trip through `PipelineConfig`, which
420 /// uses `#[serde(flatten)]` on `RuntimeConfig`. With `serde_json`'s
421 /// `arbitrary_precision` feature enabled, the serde `Content` buffer
422 /// represents numbers as maps, which breaks plain `f64`
423 /// deserialization (serde-rs/json#1157). The `serde_via_value`
424 /// workaround on each `Option<f64>` field fixes this.
425 #[test]
426 fn dev_tweaks_f64_roundtrip_through_pipeline_config() {
427 let rc = RuntimeConfig {
428 dev_tweaks: DevTweaks {
429 bloom_false_positive_rate: Some(0.0),
430 balancer_balance_tax: Some(1.1),
431 balancer_min_relative_improvement_threshold: Some(1.2),
432 balancer_key_distribution_refresh_threshold: Some(0.1),
433 ..Default::default()
434 },
435 ..Default::default()
436 };
437 let pc = PipelineConfig {
438 global: rc,
439 multihost: None,
440 name: Some("test-pipeline".into()),
441 given_name: None,
442 storage_config: None,
443 secrets_dir: None,
444 inputs: Default::default(),
445 outputs: Default::default(),
446 program_ir: None,
447 };
448
449 // JSON string round-trip (the path the pipeline process takes).
450 let json = serde_json::to_string_pretty(&pc).unwrap();
451 let pc2: PipelineConfig = serde_json::from_str(&json)
452 .expect("JSON string round-trip of PipelineConfig with f64 dev_tweaks must succeed");
453 assert_eq!(pc2.global.dev_tweaks.bloom_false_positive_rate, Some(0.0));
454 assert_eq!(pc2.global.dev_tweaks.balancer_balance_tax, Some(1.1));
455 assert_eq!(
456 pc2.global
457 .dev_tweaks
458 .balancer_min_relative_improvement_threshold,
459 Some(1.2)
460 );
461 assert_eq!(
462 pc2.global
463 .dev_tweaks
464 .balancer_key_distribution_refresh_threshold,
465 Some(0.1)
466 );
467
468 // serde_json::Value round-trip (the path the pipeline manager takes).
469 let value = serde_json::to_value(&pc).unwrap();
470 let pc3: PipelineConfig = serde_json::from_value(value)
471 .expect("Value round-trip of PipelineConfig with f64 dev_tweaks must succeed");
472 assert_eq!(pc3.global.dev_tweaks.bloom_false_positive_rate, Some(0.0));
473 }
474
475 #[test]
476 fn other_options() {
477 let dt =
478 serde_json::from_value::<DevTweaks>(json!({"xyzzy": 1.0, "foobar": {"key": "value"}}))
479 .unwrap();
480 assert_eq!(
481 &dt.other_options,
482 &BTreeMap::from_iter([
483 (String::from("xyzzy"), json!(1.0)),
484 (String::from("foobar"), json!({"key": "value"}))
485 ]),
486 );
487 }
488}