Skip to main content

arete_server/
sorted_cache.rs

1//! Sorted view cache for maintaining ordered entity collections.
2//!
3//! This module provides incremental maintenance of sorted entity views,
4//! enabling efficient windowed subscriptions (take/skip) with minimal
5//! recomputation on updates.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::cmp::Ordering;
10use std::collections::{BTreeMap, HashMap};
11
12/// A sortable key that combines the sort value with entity key for stable ordering.
13/// Uses (sort_value, entity_key) tuple to ensure deterministic ordering even when
14/// sort values are equal.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct SortKey {
17    /// The extracted sort value (as comparable bytes)
18    sort_value: SortValue,
19    /// Entity key for tie-breaking
20    entity_key: String,
21    /// Direction to apply to the sort value comparison.
22    order: SortOrder,
23}
24
25impl PartialOrd for SortKey {
26    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
27        Some(self.cmp(other))
28    }
29}
30
31impl Ord for SortKey {
32    fn cmp(&self, other: &Self) -> Ordering {
33        if self.order != other.order {
34            return match (self.order, other.order) {
35                (SortOrder::Asc, SortOrder::Desc) => Ordering::Less,
36                (SortOrder::Desc, SortOrder::Asc) => Ordering::Greater,
37                _ => Ordering::Equal,
38            };
39        }
40
41        // An entity with no sort value (missing or `null`) cannot be ranked,
42        // so it sorts after every entity that has one in *both* directions:
43        // it must never take a window slot from a ranked entity. `desc`
44        // reverses only the comparison between two present values.
45        let sort_order = match (&self.sort_value, &other.sort_value, self.order) {
46            (SortValue::Null, SortValue::Null, _) => Ordering::Equal,
47            (SortValue::Null, _, _) => Ordering::Greater,
48            (_, SortValue::Null, _) => Ordering::Less,
49            (_, _, SortOrder::Asc) => self.sort_value.cmp(&other.sort_value),
50            (_, _, SortOrder::Desc) => self.sort_value.cmp(&other.sort_value).reverse(),
51        };
52
53        match sort_order {
54            Ordering::Equal => self.entity_key.cmp(&other.entity_key),
55            other => other,
56        }
57    }
58}
59
60/// Comparable sort value extracted from JSON
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum SortValue {
63    Null,
64    Bool(bool),
65    Integer(i64),
66    Float(OrderedFloat),
67    String(String),
68}
69
70impl Ord for SortValue {
71    fn cmp(&self, other: &Self) -> Ordering {
72        match (self, other) {
73            (SortValue::Null, SortValue::Null) => Ordering::Equal,
74            (SortValue::Null, _) => Ordering::Less,
75            (_, SortValue::Null) => Ordering::Greater,
76            (SortValue::Bool(a), SortValue::Bool(b)) => a.cmp(b),
77            (SortValue::Integer(a), SortValue::Integer(b)) => a.cmp(b),
78            (SortValue::Float(a), SortValue::Float(b)) => a.cmp(b),
79            (SortValue::String(a), SortValue::String(b)) => {
80                compare_decimal_strings(a, b).unwrap_or_else(|| a.cmp(b))
81            }
82            // Cross-type comparisons: numbers < strings
83            (SortValue::Integer(_), SortValue::String(_)) => Ordering::Less,
84            (SortValue::String(_), SortValue::Integer(_)) => Ordering::Greater,
85            (SortValue::Float(_), SortValue::String(_)) => Ordering::Less,
86            (SortValue::String(_), SortValue::Float(_)) => Ordering::Greater,
87            // Integer vs Float: convert to float
88            (SortValue::Integer(a), SortValue::Float(b)) => OrderedFloat(*a as f64).cmp(b),
89            (SortValue::Float(a), SortValue::Integer(b)) => a.cmp(&OrderedFloat(*b as f64)),
90            // Bool vs others
91            (SortValue::Bool(_), _) => Ordering::Less,
92            (_, SortValue::Bool(_)) => Ordering::Greater,
93        }
94    }
95}
96
97impl PartialOrd for SortValue {
98    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
99        Some(self.cmp(other))
100    }
101}
102
103/// Wrapper for f64 that implements Ord (treats NaN as less than all values)
104#[derive(Debug, Clone, Copy, PartialEq)]
105pub struct OrderedFloat(pub f64);
106
107impl Eq for OrderedFloat {}
108
109impl Ord for OrderedFloat {
110    fn cmp(&self, other: &Self) -> Ordering {
111        self.0.partial_cmp(&other.0).unwrap_or_else(|| {
112            if self.0.is_nan() && other.0.is_nan() {
113                Ordering::Equal
114            } else if self.0.is_nan() {
115                Ordering::Less
116            } else {
117                Ordering::Greater
118            }
119        })
120    }
121}
122
123impl PartialOrd for OrderedFloat {
124    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
125        Some(self.cmp(other))
126    }
127}
128
129/// Sort order for the cache
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "lowercase")]
132pub enum SortOrder {
133    Asc,
134    Desc,
135}
136
137impl From<crate::materialized_view::SortOrder> for SortOrder {
138    fn from(order: crate::materialized_view::SortOrder) -> Self {
139        match order {
140            crate::materialized_view::SortOrder::Asc => SortOrder::Asc,
141            crate::materialized_view::SortOrder::Desc => SortOrder::Desc,
142        }
143    }
144}
145
146/// Delta representing a change to a client's windowed view
147#[derive(Debug, Clone, PartialEq)]
148pub enum ViewDelta {
149    /// No change to the client's window
150    None,
151    /// Entity was added to the window
152    Add { key: String, entity: Value },
153    /// Entity was removed from the window
154    Remove { key: String },
155    /// Entity in the window was updated
156    Update { key: String, entity: Value },
157}
158
159/// Sorted view cache maintaining entities in sort order.
160///
161/// # Bounding
162///
163/// The cache holds a full copy of every entity it has been given, so callers
164/// that feed it from a bounded source (the projector and snapshot restore feed
165/// it from the LRU-capped [`EntityCache`](crate::EntityCache)) should use
166/// [`upsert_bounded`](Self::upsert_bounded) or
167/// [`trim_to_max_entries`](Self::trim_to_max_entries) with that source's cap.
168///
169/// Entries are evicted from the *bottom* of the sort order, not by recency.
170/// Evicting the least-recently-updated entries (mirroring the entity cache)
171/// would drop a top-ranked entity that simply has not updated recently and
172/// break leaderboard-style `sort` + `take` views. Evicting everything beyond
173/// position `max_entries` keeps every window with `skip + take <= max_entries`
174/// exact. An evicted entity re-enters on its next update, because the
175/// projector re-reads the full entity from the entity cache before upserting.
176///
177/// Known edge: after entities are removed from (or move down out of) the top
178/// of the order, a previously evicted entity that has not updated since is
179/// missing from the cache until it next updates, so a window near the cap can
180/// under-fill or show a lower-ranked entity in its place until then.
181#[derive(Debug)]
182pub struct SortedViewCache {
183    /// View identifier
184    view_id: String,
185    /// Field path to sort by (e.g., ["id", "round_id"])
186    sort_field: Vec<String>,
187    /// Sort order
188    order: SortOrder,
189    /// Sorted entries: SortKey -> entity_key (for iteration in order)
190    sorted: BTreeMap<SortKey, ()>,
191    /// Entity data: entity_key -> (SortKey, Value)
192    entities: HashMap<String, (SortKey, Value)>,
193    /// Ordered keys cache (rebuilt on structural changes)
194    keys_cache: Vec<String>,
195    /// Whether keys_cache needs rebuild
196    cache_dirty: bool,
197}
198
199impl SortedViewCache {
200    pub fn new(view_id: String, sort_field: Vec<String>, order: SortOrder) -> Self {
201        Self {
202            view_id,
203            sort_field,
204            order,
205            sorted: BTreeMap::new(),
206            entities: HashMap::new(),
207            keys_cache: Vec::new(),
208            cache_dirty: true,
209        }
210    }
211
212    pub fn view_id(&self) -> &str {
213        &self.view_id
214    }
215
216    pub fn len(&self) -> usize {
217        self.entities.len()
218    }
219
220    pub fn is_empty(&self) -> bool {
221        self.entities.is_empty()
222    }
223
224    /// Insert or update an entity, returns the position where it was inserted
225    pub fn upsert(&mut self, entity_key: String, entity: Value) -> UpsertResult {
226        let sort_value = self.extract_sort_value(&entity);
227
228        // Check if entity already exists. Taken out rather than copied: every
229        // branch below puts the merged entity back.
230        if let Some((old_sort_key, old_entity)) = self.entities.remove(&entity_key) {
231            let effective_sort_value = if matches!(sort_value, SortValue::Null)
232                && !matches!(old_sort_key.sort_value, SortValue::Null)
233            {
234                old_sort_key.sort_value.clone()
235            } else {
236                sort_value
237            };
238
239            let new_sort_key = SortKey {
240                sort_value: effective_sort_value,
241                entity_key: entity_key.clone(),
242                order: self.order,
243            };
244
245            // Merge incoming entity with existing to preserve fields not in the update
246            let merged_entity = Self::deep_merge(old_entity, entity);
247
248            if old_sort_key == new_sort_key {
249                // Sort key unchanged - just update entity data
250                self.entities
251                    .insert(entity_key.clone(), (new_sort_key, merged_entity));
252                // Position unchanged, no structural change
253                let position = self.find_position(&entity_key);
254                return UpsertResult::Updated { position };
255            }
256
257            // Sort key changed - need to reposition
258            self.sorted.remove(&old_sort_key);
259            self.sorted.insert(new_sort_key.clone(), ());
260            self.entities
261                .insert(entity_key.clone(), (new_sort_key, merged_entity));
262            self.cache_dirty = true;
263
264            let position = self.find_position(&entity_key);
265            return UpsertResult::Inserted { position };
266        }
267
268        let new_sort_key = SortKey {
269            sort_value,
270            entity_key: entity_key.clone(),
271            order: self.order,
272        };
273
274        self.sorted.insert(new_sort_key.clone(), ());
275        self.entities
276            .insert(entity_key.clone(), (new_sort_key, entity));
277        self.cache_dirty = true;
278
279        let position = self.find_position(&entity_key);
280
281        UpsertResult::Inserted { position }
282    }
283
284    /// Whether upserting `entity` under `entity_key` into a cache bounded at
285    /// `max_entries` would keep it: the cache already holds the key, has
286    /// room, or the entity sorts before its current last entry.
287    ///
288    /// Lets a caller skip copying an entity that [`Self::upsert_bounded`]
289    /// would evict straight away, which in a busy view is most of them.
290    pub fn would_keep(&self, entity_key: &str, entity: &Value, max_entries: usize) -> bool {
291        if self.entities.contains_key(entity_key) || self.sorted.len() < max_entries {
292            return true;
293        }
294        let Some((last, ())) = self.sorted.last_key_value() else {
295            return true;
296        };
297        let candidate = SortKey {
298            sort_value: self.extract_sort_value(entity),
299            entity_key: entity_key.to_string(),
300            order: self.order,
301        };
302        candidate < *last
303    }
304
305    /// Upsert an entity, then evict from the bottom of the sort order so the
306    /// cache holds at most `max_entries` entities.
307    ///
308    /// If the upserted entity itself sorts beyond `max_entries` it is evicted
309    /// immediately and the returned position is `>= max_entries`.
310    pub fn upsert_bounded(
311        &mut self,
312        entity_key: String,
313        entity: Value,
314        max_entries: usize,
315    ) -> UpsertResult {
316        let result = self.upsert(entity_key, entity);
317        self.trim_to_max_entries(max_entries);
318        result
319    }
320
321    /// Evict entities from the bottom of the sort order until at most
322    /// `max_entries` remain. Returns the number of evicted entities.
323    ///
324    /// Each eviction is an `O(log n)` pop from the end of the ordered index,
325    /// so a batch of evictions (e.g. after a bulk rebuild) costs no more than
326    /// the inserts that caused it. The ordered-keys cache is truncated in place
327    /// when it is current, so trimming does not force a full rebuild.
328    pub fn trim_to_max_entries(&mut self, max_entries: usize) -> usize {
329        let mut evicted = 0;
330        while self.sorted.len() > max_entries {
331            let Some((sort_key, ())) = self.sorted.pop_last() else {
332                break;
333            };
334            self.entities.remove(&sort_key.entity_key);
335            evicted += 1;
336        }
337        if evicted > 0 && !self.cache_dirty {
338            // Only the tail of the order was removed, so the prefix is still
339            // exact.
340            self.keys_cache.truncate(self.sorted.len());
341        }
342        evicted
343    }
344
345    fn deep_merge(base: Value, patch: Value) -> Value {
346        match (base, patch) {
347            (Value::Object(mut base_map), Value::Object(patch_map)) => {
348                for (key, patch_value) in patch_map {
349                    if let Some(base_value) = base_map.remove(&key) {
350                        base_map.insert(key, Self::deep_merge(base_value, patch_value));
351                    } else {
352                        base_map.insert(key, patch_value);
353                    }
354                }
355                Value::Object(base_map)
356            }
357            (_, patch) => patch,
358        }
359    }
360
361    /// Remove an entity, returns the position it was at
362    pub fn remove(&mut self, entity_key: &str) -> Option<usize> {
363        if let Some((sort_key, _)) = self.entities.remove(entity_key) {
364            let position = self.find_position_by_sort_key(&sort_key);
365            self.sorted.remove(&sort_key);
366            self.cache_dirty = true;
367            Some(position)
368        } else {
369            None
370        }
371    }
372
373    /// Get entity by key
374    pub fn get(&self, entity_key: &str) -> Option<&Value> {
375        self.entities.get(entity_key).map(|(_, v)| v)
376    }
377
378    /// Get ordered keys (rebuilds cache if dirty)
379    pub fn ordered_keys(&mut self) -> &[String] {
380        if self.cache_dirty {
381            self.rebuild_keys_cache();
382        }
383        &self.keys_cache
384    }
385
386    /// Get a window of entities
387    pub fn get_window(&mut self, skip: usize, take: usize) -> Vec<(String, Value)> {
388        if self.cache_dirty {
389            self.rebuild_keys_cache();
390        }
391
392        self.keys_cache
393            .iter()
394            .skip(skip)
395            .take(take)
396            .filter_map(|key| {
397                self.entities
398                    .get(key)
399                    .map(|(_, v)| (key.clone(), v.clone()))
400            })
401            .collect()
402    }
403
404    /// Get every entity in deterministic sort order for query-side filtering.
405    pub fn get_all_ordered(&mut self) -> Vec<(String, Value)> {
406        if self.cache_dirty {
407            self.rebuild_keys_cache();
408        }
409
410        self.keys_cache
411            .iter()
412            .filter_map(|key| {
413                self.entities
414                    .get(key)
415                    .map(|(_, value)| (key.clone(), value.clone()))
416            })
417            .collect()
418    }
419
420    /// Compute deltas for a client with a specific window
421    pub fn compute_window_deltas(
422        &mut self,
423        old_window_keys: &[String],
424        skip: usize,
425        take: usize,
426    ) -> Vec<ViewDelta> {
427        if self.cache_dirty {
428            self.rebuild_keys_cache();
429        }
430
431        let new_window_keys: Vec<&String> = self.keys_cache.iter().skip(skip).take(take).collect();
432
433        let old_set: std::collections::HashSet<&String> = old_window_keys.iter().collect();
434        let new_set: std::collections::HashSet<&String> = new_window_keys.iter().cloned().collect();
435
436        let mut deltas = Vec::new();
437
438        // Removed from window
439        for key in old_set.difference(&new_set) {
440            deltas.push(ViewDelta::Remove {
441                key: (*key).clone(),
442            });
443        }
444
445        // Added to window
446        for key in new_set.difference(&old_set) {
447            if let Some((_, entity)) = self.entities.get(*key) {
448                deltas.push(ViewDelta::Add {
449                    key: (*key).clone(),
450                    entity: entity.clone(),
451                });
452            }
453        }
454
455        deltas
456    }
457
458    fn extract_sort_value(&self, entity: &Value) -> SortValue {
459        let mut current = entity;
460        for segment in &self.sort_field {
461            match current.get(segment) {
462                Some(v) => current = v,
463                None => return SortValue::Null,
464            }
465        }
466
467        value_to_sort_value(current)
468    }
469
470    fn find_position(&self, entity_key: &str) -> usize {
471        if let Some((sort_key, _)) = self.entities.get(entity_key) {
472            self.find_position_by_sort_key(sort_key)
473        } else {
474            0
475        }
476    }
477
478    fn find_position_by_sort_key(&self, sort_key: &SortKey) -> usize {
479        self.sorted.range(..sort_key).count()
480    }
481
482    fn rebuild_keys_cache(&mut self) {
483        self.keys_cache = self.sorted.keys().map(|sk| sk.entity_key.clone()).collect();
484        self.cache_dirty = false;
485    }
486}
487
488/// Result of an upsert operation
489#[derive(Debug, Clone, PartialEq)]
490pub enum UpsertResult {
491    /// Entity was inserted at a new position
492    Inserted { position: usize },
493    /// Entity was updated (may or may not have moved)
494    Updated { position: usize },
495}
496
497fn value_to_sort_value(v: &Value) -> SortValue {
498    match v {
499        Value::Null => SortValue::Null,
500        Value::Bool(b) => SortValue::Bool(*b),
501        Value::Number(n) => {
502            if let Some(i) = n.as_i64() {
503                SortValue::Integer(i)
504            } else if let Some(f) = n.as_f64() {
505                SortValue::Float(OrderedFloat(f))
506            } else {
507                SortValue::Null
508            }
509        }
510        Value::String(s) => SortValue::String(s.clone()),
511        _ => SortValue::Null,
512    }
513}
514
515fn compare_decimal_strings(left: &str, right: &str) -> Option<Ordering> {
516    fn parts(value: &str) -> Option<(bool, &str)> {
517        let (negative, digits) = match value.strip_prefix('-') {
518            Some(digits) => (true, digits),
519            None => (false, value),
520        };
521        if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
522            return None;
523        }
524
525        let digits = digits.trim_start_matches('0');
526        let digits = if digits.is_empty() { "0" } else { digits };
527        Some((negative && digits != "0", digits))
528    }
529
530    let (left_negative, left_digits) = parts(left)?;
531    let (right_negative, right_digits) = parts(right)?;
532
533    match (left_negative, right_negative) {
534        (true, false) => Some(Ordering::Less),
535        (false, true) => Some(Ordering::Greater),
536        _ => {
537            let magnitude = left_digits
538                .len()
539                .cmp(&right_digits.len())
540                .then_with(|| left_digits.cmp(right_digits));
541            Some(if left_negative {
542                magnitude.reverse()
543            } else {
544                magnitude
545            })
546        }
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use serde_json::json;
554
555    #[test]
556    fn test_sorted_cache_basic() {
557        let mut cache = SortedViewCache::new(
558            "test/latest".to_string(),
559            vec!["id".to_string()],
560            SortOrder::Desc,
561        );
562
563        cache.upsert("a".to_string(), json!({"id": 1, "name": "first"}));
564        cache.upsert("b".to_string(), json!({"id": 3, "name": "third"}));
565        cache.upsert("c".to_string(), json!({"id": 2, "name": "second"}));
566
567        let keys = cache.ordered_keys();
568        // Desc order: 3, 2, 1
569        assert_eq!(keys, vec!["b", "c", "a"]);
570    }
571
572    #[test]
573    fn test_sorted_cache_window() {
574        let mut cache = SortedViewCache::new(
575            "test/latest".to_string(),
576            vec!["id".to_string()],
577            SortOrder::Desc,
578        );
579
580        for i in 1..=10 {
581            cache.upsert(format!("e{}", i), json!({"id": i}));
582        }
583
584        // Desc order: 10, 9, 8, 7, 6, 5, 4, 3, 2, 1
585        let window = cache.get_window(0, 3);
586        assert_eq!(window.len(), 3);
587        assert_eq!(window[0].0, "e10");
588        assert_eq!(window[1].0, "e9");
589        assert_eq!(window[2].0, "e8");
590
591        let window = cache.get_window(3, 3);
592        assert_eq!(window[0].0, "e7");
593    }
594
595    #[test]
596    fn all_ordered_preserves_stable_sort_and_tie_breaking() {
597        let mut cache = SortedViewCache::new(
598            "test/latest".to_string(),
599            vec!["score".to_string()],
600            SortOrder::Desc,
601        );
602        cache.upsert("b".to_string(), json!({"score": 10}));
603        cache.upsert("a".to_string(), json!({"score": 10}));
604        cache.upsert("c".to_string(), json!({"score": 9}));
605
606        let keys: Vec<_> = cache
607            .get_all_ordered()
608            .into_iter()
609            .map(|(key, _)| key)
610            .collect();
611        assert_eq!(keys, ["a", "b", "c"]);
612    }
613
614    #[test]
615    fn test_sorted_cache_update_moves_position() {
616        let mut cache = SortedViewCache::new(
617            "test/latest".to_string(),
618            vec!["score".to_string()],
619            SortOrder::Desc,
620        );
621
622        cache.upsert("a".to_string(), json!({"score": 10}));
623        cache.upsert("b".to_string(), json!({"score": 20}));
624        cache.upsert("c".to_string(), json!({"score": 15}));
625
626        // Order: b(20), c(15), a(10)
627        assert_eq!(cache.ordered_keys(), vec!["b", "c", "a"]);
628
629        // Update a to have highest score
630        cache.upsert("a".to_string(), json!({"score": 25}));
631
632        // New order: a(25), b(20), c(15)
633        assert_eq!(cache.ordered_keys(), vec!["a", "b", "c"]);
634    }
635
636    #[test]
637    fn test_sorted_cache_remove() {
638        let mut cache = SortedViewCache::new(
639            "test/latest".to_string(),
640            vec!["id".to_string()],
641            SortOrder::Asc,
642        );
643
644        cache.upsert("a".to_string(), json!({"id": 1}));
645        cache.upsert("b".to_string(), json!({"id": 2}));
646        cache.upsert("c".to_string(), json!({"id": 3}));
647
648        assert_eq!(cache.len(), 3);
649
650        let pos = cache.remove("b");
651        assert_eq!(pos, Some(1));
652        assert_eq!(cache.len(), 2);
653        assert_eq!(cache.ordered_keys(), vec!["a", "c"]);
654    }
655
656    #[test]
657    fn test_compute_window_deltas() {
658        let mut cache = SortedViewCache::new(
659            "test/latest".to_string(),
660            vec!["id".to_string()],
661            SortOrder::Desc,
662        );
663
664        // Initial: 5, 4, 3, 2, 1
665        for i in 1..=5 {
666            cache.upsert(format!("e{}", i), json!({"id": i}));
667        }
668
669        let old_window: Vec<String> = vec!["e5".to_string(), "e4".to_string(), "e3".to_string()];
670
671        // Add e6 (new top)
672        cache.upsert("e6".to_string(), json!({"id": 6}));
673
674        // New order: 6, 5, 4, 3, 2, 1
675        // New top 3: e6, e5, e4
676        let deltas = cache.compute_window_deltas(&old_window, 0, 3);
677
678        assert_eq!(deltas.len(), 2);
679        // e3 removed from window
680        assert!(deltas
681            .iter()
682            .any(|d| matches!(d, ViewDelta::Remove { key } if key == "e3")));
683        // e6 added to window
684        assert!(deltas
685            .iter()
686            .any(|d| matches!(d, ViewDelta::Add { key, .. } if key == "e6")));
687    }
688
689    fn keys(cache: &mut SortedViewCache) -> Vec<String> {
690        cache.ordered_keys().to_vec()
691    }
692
693    #[test]
694    fn bounded_upsert_evicts_bottom_of_desc_order() {
695        let mut cache = SortedViewCache::new(
696            "test/top".to_string(),
697            vec!["score".to_string()],
698            SortOrder::Desc,
699        );
700
701        for i in 1..=10 {
702            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 4);
703            assert!(cache.len() <= 4);
704        }
705
706        assert_eq!(cache.len(), 4);
707        assert_eq!(keys(&mut cache), ["e10", "e9", "e8", "e7"]);
708        assert!(cache.get("e1").is_none());
709        assert!(cache.get("e6").is_none());
710    }
711
712    #[test]
713    fn would_keep_matches_what_a_bounded_upsert_keeps() {
714        let mut cache = SortedViewCache::new(
715            "test/top".to_string(),
716            vec!["score".to_string()],
717            SortOrder::Desc,
718        );
719        for i in 1..=10 {
720            let entity = json!({"score": i});
721            let expected = cache.would_keep(&format!("e{i}"), &entity, 4);
722            cache.upsert_bounded(format!("e{i}"), entity, 4);
723            assert!(expected, "an entity above a full cache's tail is kept");
724        }
725        // e7 is the tail of [e10, e9, e8, e7].
726        for (key, score, kept) in [("e0", 0, false), ("e6", 6, false), ("e11", 11, true)] {
727            let entity = json!({"score": score});
728            assert_eq!(cache.would_keep(key, &entity, 4), kept, "{key}");
729            let mut copy = SortedViewCache::new(
730                "test/top".to_string(),
731                vec!["score".to_string()],
732                SortOrder::Desc,
733            );
734            for existing in keys(&mut cache) {
735                let value = cache.get(&existing).unwrap().clone();
736                copy.upsert_bounded(existing, value, 4);
737            }
738            copy.upsert_bounded(key.to_string(), entity, 4);
739            assert_eq!(copy.get(key).is_some(), kept, "{key}");
740        }
741        // A held entity is always kept, even when its new value sorts last.
742        assert!(cache.would_keep("e8", &json!({"score": -1}), 4));
743    }
744
745    #[test]
746    fn bounded_upsert_evicts_bottom_of_asc_order() {
747        let mut cache = SortedViewCache::new(
748            "test/bottom".to_string(),
749            vec!["score".to_string()],
750            SortOrder::Asc,
751        );
752
753        for i in (1..=10).rev() {
754            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 4);
755            assert!(cache.len() <= 4);
756        }
757
758        assert_eq!(cache.len(), 4);
759        assert_eq!(keys(&mut cache), ["e1", "e2", "e3", "e4"]);
760        assert!(cache.get("e10").is_none());
761    }
762
763    #[test]
764    fn bounded_upsert_does_not_evict_stale_top_entities() {
765        let mut cache = SortedViewCache::new(
766            "test/top".to_string(),
767            vec!["score".to_string()],
768            SortOrder::Desc,
769        );
770
771        // The leader is inserted first and never updated again; recency-based
772        // eviction would drop it.
773        cache.upsert_bounded("leader".to_string(), json!({"score": 1_000}), 3);
774        for i in 1..=20 {
775            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 3);
776        }
777
778        assert_eq!(keys(&mut cache), ["leader", "e20", "e19"]);
779    }
780
781    #[test]
782    fn windows_within_cap_match_unbounded_cache() {
783        let mut bounded = SortedViewCache::new(
784            "test/top".to_string(),
785            vec!["score".to_string()],
786            SortOrder::Desc,
787        );
788        let mut unbounded = SortedViewCache::new(
789            "test/top".to_string(),
790            vec!["score".to_string()],
791            SortOrder::Desc,
792        );
793
794        // Scores arrive out of order within each round and every entity moves
795        // up on each later round. Entities never move down, so nothing
796        // evicted can belong back inside the cap (see the edge-case test
797        // below for what happens when they do).
798        for i in 0..200u64 {
799            let key = format!("e{}", i % 60);
800            let score = (i / 60) * 1_000 + (i * 37) % 101;
801            let entity = json!({"score": score, "n": i});
802            bounded.upsert_bounded(key.clone(), entity.clone(), 25);
803            unbounded.upsert(key, entity);
804        }
805
806        assert_eq!(bounded.len(), 25);
807        for (skip, take) in [(0, 25), (0, 10), (5, 20), (24, 1)] {
808            assert_eq!(
809                bounded.get_window(skip, take),
810                unbounded.get_window(skip, take),
811                "window skip={skip} take={take}"
812            );
813        }
814    }
815
816    #[test]
817    fn evicted_entity_is_missing_after_top_moves_down_until_it_updates() {
818        let mut cache = SortedViewCache::new(
819            "test/top".to_string(),
820            vec!["score".to_string()],
821            SortOrder::Desc,
822        );
823
824        for i in 1..=4 {
825            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 3);
826        }
827        assert_eq!(keys(&mut cache), ["e4", "e3", "e2"]);
828
829        // The leader drops to the bottom; e1 would now rank third but was
830        // evicted and has not updated, so e4 holds third place instead.
831        cache.upsert_bounded("e4".to_string(), json!({"score": 0}), 3);
832        assert_eq!(keys(&mut cache), ["e3", "e2", "e4"]);
833
834        // Once e1 updates it re-enters at its correct position.
835        cache.upsert_bounded("e1".to_string(), json!({"score": 1}), 3);
836        assert_eq!(keys(&mut cache), ["e3", "e2", "e1"]);
837    }
838
839    #[test]
840    fn evicted_entity_reenters_when_upserted_again() {
841        let mut cache = SortedViewCache::new(
842            "test/top".to_string(),
843            vec!["score".to_string()],
844            SortOrder::Desc,
845        );
846
847        for i in 1..=5 {
848            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 3);
849        }
850        assert!(cache.get("e1").is_none());
851
852        let result = cache.upsert_bounded("e1".to_string(), json!({"score": 100}), 3);
853        assert_eq!(result, UpsertResult::Inserted { position: 0 });
854        assert_eq!(keys(&mut cache), ["e1", "e5", "e4"]);
855        assert_eq!(cache.len(), 3);
856    }
857
858    #[test]
859    fn upsert_below_full_cap_is_evicted_immediately() {
860        let mut cache = SortedViewCache::new(
861            "test/top".to_string(),
862            vec!["score".to_string()],
863            SortOrder::Desc,
864        );
865
866        for i in 10..=12 {
867            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 3);
868        }
869        let result = cache.upsert_bounded("low".to_string(), json!({"score": 1}), 3);
870
871        assert_eq!(result, UpsertResult::Inserted { position: 3 });
872        assert!(cache.get("low").is_none());
873        assert_eq!(keys(&mut cache), ["e12", "e11", "e10"]);
874    }
875
876    #[test]
877    fn trim_keeps_keys_cache_and_entities_consistent() {
878        let mut cache = SortedViewCache::new(
879            "test/top".to_string(),
880            vec!["score".to_string()],
881            SortOrder::Desc,
882        );
883
884        for i in 1..=10 {
885            cache.upsert(format!("e{i}"), json!({"score": i}));
886        }
887        // Build the keys cache so the trim takes the in-place truncate path.
888        assert_eq!(cache.ordered_keys().len(), 10);
889
890        assert_eq!(cache.trim_to_max_entries(4), 6);
891        assert_eq!(cache.trim_to_max_entries(4), 0);
892        assert_eq!(cache.len(), 4);
893        assert_eq!(keys(&mut cache), ["e10", "e9", "e8", "e7"]);
894        assert_eq!(cache.get_all_ordered().len(), 4);
895        assert_eq!(cache.remove("e7"), Some(3));
896        assert_eq!(keys(&mut cache), ["e10", "e9", "e8"]);
897    }
898
899    #[test]
900    fn test_nested_sort_field() {
901        let mut cache = SortedViewCache::new(
902            "test/latest".to_string(),
903            vec!["id".to_string(), "round_id".to_string()],
904            SortOrder::Desc,
905        );
906
907        cache.upsert("a".to_string(), json!({"id": {"round_id": 1}}));
908        cache.upsert("b".to_string(), json!({"id": {"round_id": 3}}));
909        cache.upsert("c".to_string(), json!({"id": {"round_id": 2}}));
910
911        let keys = cache.ordered_keys();
912        assert_eq!(keys, vec!["b", "c", "a"]);
913    }
914
915    #[test]
916    fn test_nested_decimal_string_sort_field() {
917        let mut cache = SortedViewCache::new(
918            "test/latest".to_string(),
919            vec!["id".to_string(), "round_id".to_string()],
920            SortOrder::Desc,
921        );
922
923        cache.upsert("9".to_string(), json!({"id": {"round_id": "9"}}));
924        cache.upsert("100".to_string(), json!({"id": {"round_id": "100"}}));
925        cache.upsert("10".to_string(), json!({"id": {"round_id": "10"}}));
926
927        assert_eq!(cache.ordered_keys(), vec!["100", "10", "9"]);
928    }
929
930    #[test]
931    fn test_descending_string_sort_field() {
932        let mut cache = SortedViewCache::new(
933            "test/latest".to_string(),
934            vec!["name".to_string()],
935            SortOrder::Desc,
936        );
937
938        cache.upsert("a".to_string(), json!({"name": "alpha"}));
939        cache.upsert("c".to_string(), json!({"name": "charlie"}));
940        cache.upsert("b".to_string(), json!({"name": "bravo"}));
941
942        assert_eq!(cache.ordered_keys(), vec!["c", "b", "a"]);
943    }
944
945    #[test]
946    fn test_update_with_missing_sort_field_preserves_position() {
947        let mut cache = SortedViewCache::new(
948            "test/latest".to_string(),
949            vec!["id".to_string(), "round_id".to_string()],
950            SortOrder::Desc,
951        );
952
953        cache.upsert(
954            "100".to_string(),
955            json!({"id": {"round_id": 100}, "data": "initial"}),
956        );
957        cache.upsert(
958            "200".to_string(),
959            json!({"id": {"round_id": 200}, "data": "initial"}),
960        );
961        cache.upsert(
962            "300".to_string(),
963            json!({"id": {"round_id": 300}, "data": "initial"}),
964        );
965
966        assert_eq!(cache.ordered_keys(), vec!["300", "200", "100"]);
967
968        cache.upsert("200".to_string(), json!({"data": "updated_without_id"}));
969
970        assert_eq!(
971            cache.ordered_keys(),
972            vec!["300", "200", "100"],
973            "Entity 200 should retain its position even when updated without sort field"
974        );
975
976        let entity = cache.get("200").unwrap();
977        assert_eq!(entity["data"], "updated_without_id");
978    }
979
980    #[test]
981    fn test_new_entity_with_missing_sort_field_sorts_last() {
982        for order in [SortOrder::Desc, SortOrder::Asc] {
983            let mut cache = SortedViewCache::new(
984                "test/latest".to_string(),
985                vec!["id".to_string(), "round_id".to_string()],
986                order,
987            );
988
989            cache.upsert("100".to_string(), json!({"id": {"round_id": 100}}));
990            cache.upsert("200".to_string(), json!({"id": {"round_id": 200}}));
991            cache.upsert("new".to_string(), json!({"data": "no_sort_field"}));
992            cache.upsert("nil".to_string(), json!({"id": {"round_id": null}}));
993
994            let ranked = match order {
995                SortOrder::Desc => ["200", "100"],
996                SortOrder::Asc => ["100", "200"],
997            };
998            assert_eq!(
999                cache.ordered_keys(),
1000                [ranked[0], ranked[1], "new", "nil"],
1001                "{order:?}: an entity without a sort value never outranks one with it; \
1002                 among themselves they order by key"
1003            );
1004        }
1005    }
1006
1007    /// The live `latest` failure: a window full of ranked entities must not
1008    /// admit an unranked one, since it would evict a real member.
1009    #[test]
1010    fn a_full_window_does_not_admit_an_entity_without_a_sort_value() {
1011        let mut cache = SortedViewCache::new(
1012            "test/latest".to_string(),
1013            vec!["id".to_string(), "round_id".to_string()],
1014            SortOrder::Desc,
1015        );
1016        for round in [100, 200, 300] {
1017            cache.upsert_bounded(round.to_string(), json!({"id": {"round_id": round}}), 3);
1018        }
1019        let partial = json!({"metrics": {"checkpoint_count": 7}});
1020        assert!(!cache.would_keep("1", &partial, 3));
1021        cache.upsert_bounded("1".to_string(), partial, 3);
1022        assert_eq!(cache.ordered_keys(), ["300", "200", "100"]);
1023    }
1024}