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 clear(&mut self) {
363        self.sorted.clear();
364        self.entities.clear();
365        self.cache_dirty = true;
366    }
367
368    pub fn remove(&mut self, entity_key: &str) -> Option<usize> {
369        if let Some((sort_key, _)) = self.entities.remove(entity_key) {
370            let position = self.find_position_by_sort_key(&sort_key);
371            self.sorted.remove(&sort_key);
372            self.cache_dirty = true;
373            Some(position)
374        } else {
375            None
376        }
377    }
378
379    /// Get entity by key
380    pub fn get(&self, entity_key: &str) -> Option<&Value> {
381        self.entities.get(entity_key).map(|(_, v)| v)
382    }
383
384    /// Get ordered keys (rebuilds cache if dirty)
385    pub fn ordered_keys(&mut self) -> &[String] {
386        if self.cache_dirty {
387            self.rebuild_keys_cache();
388        }
389        &self.keys_cache
390    }
391
392    /// Get a window of entities
393    pub fn get_window(&mut self, skip: usize, take: usize) -> Vec<(String, Value)> {
394        if self.cache_dirty {
395            self.rebuild_keys_cache();
396        }
397
398        self.keys_cache
399            .iter()
400            .skip(skip)
401            .take(take)
402            .filter_map(|key| {
403                self.entities
404                    .get(key)
405                    .map(|(_, v)| (key.clone(), v.clone()))
406            })
407            .collect()
408    }
409
410    /// Get every entity in deterministic sort order for query-side filtering.
411    pub fn get_all_ordered(&mut self) -> Vec<(String, Value)> {
412        if self.cache_dirty {
413            self.rebuild_keys_cache();
414        }
415
416        self.keys_cache
417            .iter()
418            .filter_map(|key| {
419                self.entities
420                    .get(key)
421                    .map(|(_, value)| (key.clone(), value.clone()))
422            })
423            .collect()
424    }
425
426    /// Compute deltas for a client with a specific window
427    pub fn compute_window_deltas(
428        &mut self,
429        old_window_keys: &[String],
430        skip: usize,
431        take: usize,
432    ) -> Vec<ViewDelta> {
433        if self.cache_dirty {
434            self.rebuild_keys_cache();
435        }
436
437        let new_window_keys: Vec<&String> = self.keys_cache.iter().skip(skip).take(take).collect();
438
439        let old_set: std::collections::HashSet<&String> = old_window_keys.iter().collect();
440        let new_set: std::collections::HashSet<&String> = new_window_keys.iter().cloned().collect();
441
442        let mut deltas = Vec::new();
443
444        // Removed from window
445        for key in old_set.difference(&new_set) {
446            deltas.push(ViewDelta::Remove {
447                key: (*key).clone(),
448            });
449        }
450
451        // Added to window
452        for key in new_set.difference(&old_set) {
453            if let Some((_, entity)) = self.entities.get(*key) {
454                deltas.push(ViewDelta::Add {
455                    key: (*key).clone(),
456                    entity: entity.clone(),
457                });
458            }
459        }
460
461        deltas
462    }
463
464    fn extract_sort_value(&self, entity: &Value) -> SortValue {
465        let mut current = entity;
466        for segment in &self.sort_field {
467            match current.get(segment) {
468                Some(v) => current = v,
469                None => return SortValue::Null,
470            }
471        }
472
473        value_to_sort_value(current)
474    }
475
476    fn find_position(&self, entity_key: &str) -> usize {
477        if let Some((sort_key, _)) = self.entities.get(entity_key) {
478            self.find_position_by_sort_key(sort_key)
479        } else {
480            0
481        }
482    }
483
484    fn find_position_by_sort_key(&self, sort_key: &SortKey) -> usize {
485        self.sorted.range(..sort_key).count()
486    }
487
488    fn rebuild_keys_cache(&mut self) {
489        self.keys_cache = self.sorted.keys().map(|sk| sk.entity_key.clone()).collect();
490        self.cache_dirty = false;
491    }
492}
493
494/// Result of an upsert operation
495#[derive(Debug, Clone, PartialEq)]
496pub enum UpsertResult {
497    /// Entity was inserted at a new position
498    Inserted { position: usize },
499    /// Entity was updated (may or may not have moved)
500    Updated { position: usize },
501}
502
503fn value_to_sort_value(v: &Value) -> SortValue {
504    match v {
505        Value::Null => SortValue::Null,
506        Value::Bool(b) => SortValue::Bool(*b),
507        Value::Number(n) => {
508            if let Some(i) = n.as_i64() {
509                SortValue::Integer(i)
510            } else if let Some(f) = n.as_f64() {
511                SortValue::Float(OrderedFloat(f))
512            } else {
513                SortValue::Null
514            }
515        }
516        Value::String(s) => SortValue::String(s.clone()),
517        _ => SortValue::Null,
518    }
519}
520
521fn compare_decimal_strings(left: &str, right: &str) -> Option<Ordering> {
522    fn parts(value: &str) -> Option<(bool, &str)> {
523        let (negative, digits) = match value.strip_prefix('-') {
524            Some(digits) => (true, digits),
525            None => (false, value),
526        };
527        if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
528            return None;
529        }
530
531        let digits = digits.trim_start_matches('0');
532        let digits = if digits.is_empty() { "0" } else { digits };
533        Some((negative && digits != "0", digits))
534    }
535
536    let (left_negative, left_digits) = parts(left)?;
537    let (right_negative, right_digits) = parts(right)?;
538
539    match (left_negative, right_negative) {
540        (true, false) => Some(Ordering::Less),
541        (false, true) => Some(Ordering::Greater),
542        _ => {
543            let magnitude = left_digits
544                .len()
545                .cmp(&right_digits.len())
546                .then_with(|| left_digits.cmp(right_digits));
547            Some(if left_negative {
548                magnitude.reverse()
549            } else {
550                magnitude
551            })
552        }
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use serde_json::json;
560
561    #[test]
562    fn test_sorted_cache_basic() {
563        let mut cache = SortedViewCache::new(
564            "test/latest".to_string(),
565            vec!["id".to_string()],
566            SortOrder::Desc,
567        );
568
569        cache.upsert("a".to_string(), json!({"id": 1, "name": "first"}));
570        cache.upsert("b".to_string(), json!({"id": 3, "name": "third"}));
571        cache.upsert("c".to_string(), json!({"id": 2, "name": "second"}));
572
573        let keys = cache.ordered_keys();
574        // Desc order: 3, 2, 1
575        assert_eq!(keys, vec!["b", "c", "a"]);
576    }
577
578    #[test]
579    fn test_sorted_cache_window() {
580        let mut cache = SortedViewCache::new(
581            "test/latest".to_string(),
582            vec!["id".to_string()],
583            SortOrder::Desc,
584        );
585
586        for i in 1..=10 {
587            cache.upsert(format!("e{}", i), json!({"id": i}));
588        }
589
590        // Desc order: 10, 9, 8, 7, 6, 5, 4, 3, 2, 1
591        let window = cache.get_window(0, 3);
592        assert_eq!(window.len(), 3);
593        assert_eq!(window[0].0, "e10");
594        assert_eq!(window[1].0, "e9");
595        assert_eq!(window[2].0, "e8");
596
597        let window = cache.get_window(3, 3);
598        assert_eq!(window[0].0, "e7");
599    }
600
601    #[test]
602    fn all_ordered_preserves_stable_sort_and_tie_breaking() {
603        let mut cache = SortedViewCache::new(
604            "test/latest".to_string(),
605            vec!["score".to_string()],
606            SortOrder::Desc,
607        );
608        cache.upsert("b".to_string(), json!({"score": 10}));
609        cache.upsert("a".to_string(), json!({"score": 10}));
610        cache.upsert("c".to_string(), json!({"score": 9}));
611
612        let keys: Vec<_> = cache
613            .get_all_ordered()
614            .into_iter()
615            .map(|(key, _)| key)
616            .collect();
617        assert_eq!(keys, ["a", "b", "c"]);
618    }
619
620    #[test]
621    fn test_sorted_cache_update_moves_position() {
622        let mut cache = SortedViewCache::new(
623            "test/latest".to_string(),
624            vec!["score".to_string()],
625            SortOrder::Desc,
626        );
627
628        cache.upsert("a".to_string(), json!({"score": 10}));
629        cache.upsert("b".to_string(), json!({"score": 20}));
630        cache.upsert("c".to_string(), json!({"score": 15}));
631
632        // Order: b(20), c(15), a(10)
633        assert_eq!(cache.ordered_keys(), vec!["b", "c", "a"]);
634
635        // Update a to have highest score
636        cache.upsert("a".to_string(), json!({"score": 25}));
637
638        // New order: a(25), b(20), c(15)
639        assert_eq!(cache.ordered_keys(), vec!["a", "b", "c"]);
640    }
641
642    #[test]
643    fn test_sorted_cache_remove() {
644        let mut cache = SortedViewCache::new(
645            "test/latest".to_string(),
646            vec!["id".to_string()],
647            SortOrder::Asc,
648        );
649
650        cache.upsert("a".to_string(), json!({"id": 1}));
651        cache.upsert("b".to_string(), json!({"id": 2}));
652        cache.upsert("c".to_string(), json!({"id": 3}));
653
654        assert_eq!(cache.len(), 3);
655
656        let pos = cache.remove("b");
657        assert_eq!(pos, Some(1));
658        assert_eq!(cache.len(), 2);
659        assert_eq!(cache.ordered_keys(), vec!["a", "c"]);
660    }
661
662    #[test]
663    fn test_compute_window_deltas() {
664        let mut cache = SortedViewCache::new(
665            "test/latest".to_string(),
666            vec!["id".to_string()],
667            SortOrder::Desc,
668        );
669
670        // Initial: 5, 4, 3, 2, 1
671        for i in 1..=5 {
672            cache.upsert(format!("e{}", i), json!({"id": i}));
673        }
674
675        let old_window: Vec<String> = vec!["e5".to_string(), "e4".to_string(), "e3".to_string()];
676
677        // Add e6 (new top)
678        cache.upsert("e6".to_string(), json!({"id": 6}));
679
680        // New order: 6, 5, 4, 3, 2, 1
681        // New top 3: e6, e5, e4
682        let deltas = cache.compute_window_deltas(&old_window, 0, 3);
683
684        assert_eq!(deltas.len(), 2);
685        // e3 removed from window
686        assert!(deltas
687            .iter()
688            .any(|d| matches!(d, ViewDelta::Remove { key } if key == "e3")));
689        // e6 added to window
690        assert!(deltas
691            .iter()
692            .any(|d| matches!(d, ViewDelta::Add { key, .. } if key == "e6")));
693    }
694
695    fn keys(cache: &mut SortedViewCache) -> Vec<String> {
696        cache.ordered_keys().to_vec()
697    }
698
699    #[test]
700    fn bounded_upsert_evicts_bottom_of_desc_order() {
701        let mut cache = SortedViewCache::new(
702            "test/top".to_string(),
703            vec!["score".to_string()],
704            SortOrder::Desc,
705        );
706
707        for i in 1..=10 {
708            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 4);
709            assert!(cache.len() <= 4);
710        }
711
712        assert_eq!(cache.len(), 4);
713        assert_eq!(keys(&mut cache), ["e10", "e9", "e8", "e7"]);
714        assert!(cache.get("e1").is_none());
715        assert!(cache.get("e6").is_none());
716    }
717
718    #[test]
719    fn would_keep_matches_what_a_bounded_upsert_keeps() {
720        let mut cache = SortedViewCache::new(
721            "test/top".to_string(),
722            vec!["score".to_string()],
723            SortOrder::Desc,
724        );
725        for i in 1..=10 {
726            let entity = json!({"score": i});
727            let expected = cache.would_keep(&format!("e{i}"), &entity, 4);
728            cache.upsert_bounded(format!("e{i}"), entity, 4);
729            assert!(expected, "an entity above a full cache's tail is kept");
730        }
731        // e7 is the tail of [e10, e9, e8, e7].
732        for (key, score, kept) in [("e0", 0, false), ("e6", 6, false), ("e11", 11, true)] {
733            let entity = json!({"score": score});
734            assert_eq!(cache.would_keep(key, &entity, 4), kept, "{key}");
735            let mut copy = SortedViewCache::new(
736                "test/top".to_string(),
737                vec!["score".to_string()],
738                SortOrder::Desc,
739            );
740            for existing in keys(&mut cache) {
741                let value = cache.get(&existing).unwrap().clone();
742                copy.upsert_bounded(existing, value, 4);
743            }
744            copy.upsert_bounded(key.to_string(), entity, 4);
745            assert_eq!(copy.get(key).is_some(), kept, "{key}");
746        }
747        // A held entity is always kept, even when its new value sorts last.
748        assert!(cache.would_keep("e8", &json!({"score": -1}), 4));
749    }
750
751    #[test]
752    fn bounded_upsert_evicts_bottom_of_asc_order() {
753        let mut cache = SortedViewCache::new(
754            "test/bottom".to_string(),
755            vec!["score".to_string()],
756            SortOrder::Asc,
757        );
758
759        for i in (1..=10).rev() {
760            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 4);
761            assert!(cache.len() <= 4);
762        }
763
764        assert_eq!(cache.len(), 4);
765        assert_eq!(keys(&mut cache), ["e1", "e2", "e3", "e4"]);
766        assert!(cache.get("e10").is_none());
767    }
768
769    #[test]
770    fn bounded_upsert_does_not_evict_stale_top_entities() {
771        let mut cache = SortedViewCache::new(
772            "test/top".to_string(),
773            vec!["score".to_string()],
774            SortOrder::Desc,
775        );
776
777        // The leader is inserted first and never updated again; recency-based
778        // eviction would drop it.
779        cache.upsert_bounded("leader".to_string(), json!({"score": 1_000}), 3);
780        for i in 1..=20 {
781            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 3);
782        }
783
784        assert_eq!(keys(&mut cache), ["leader", "e20", "e19"]);
785    }
786
787    #[test]
788    fn windows_within_cap_match_unbounded_cache() {
789        let mut bounded = SortedViewCache::new(
790            "test/top".to_string(),
791            vec!["score".to_string()],
792            SortOrder::Desc,
793        );
794        let mut unbounded = SortedViewCache::new(
795            "test/top".to_string(),
796            vec!["score".to_string()],
797            SortOrder::Desc,
798        );
799
800        // Scores arrive out of order within each round and every entity moves
801        // up on each later round. Entities never move down, so nothing
802        // evicted can belong back inside the cap (see the edge-case test
803        // below for what happens when they do).
804        for i in 0..200u64 {
805            let key = format!("e{}", i % 60);
806            let score = (i / 60) * 1_000 + (i * 37) % 101;
807            let entity = json!({"score": score, "n": i});
808            bounded.upsert_bounded(key.clone(), entity.clone(), 25);
809            unbounded.upsert(key, entity);
810        }
811
812        assert_eq!(bounded.len(), 25);
813        for (skip, take) in [(0, 25), (0, 10), (5, 20), (24, 1)] {
814            assert_eq!(
815                bounded.get_window(skip, take),
816                unbounded.get_window(skip, take),
817                "window skip={skip} take={take}"
818            );
819        }
820    }
821
822    #[test]
823    fn evicted_entity_is_missing_after_top_moves_down_until_it_updates() {
824        let mut cache = SortedViewCache::new(
825            "test/top".to_string(),
826            vec!["score".to_string()],
827            SortOrder::Desc,
828        );
829
830        for i in 1..=4 {
831            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 3);
832        }
833        assert_eq!(keys(&mut cache), ["e4", "e3", "e2"]);
834
835        // The leader drops to the bottom; e1 would now rank third but was
836        // evicted and has not updated, so e4 holds third place instead.
837        cache.upsert_bounded("e4".to_string(), json!({"score": 0}), 3);
838        assert_eq!(keys(&mut cache), ["e3", "e2", "e4"]);
839
840        // Once e1 updates it re-enters at its correct position.
841        cache.upsert_bounded("e1".to_string(), json!({"score": 1}), 3);
842        assert_eq!(keys(&mut cache), ["e3", "e2", "e1"]);
843    }
844
845    #[test]
846    fn evicted_entity_reenters_when_upserted_again() {
847        let mut cache = SortedViewCache::new(
848            "test/top".to_string(),
849            vec!["score".to_string()],
850            SortOrder::Desc,
851        );
852
853        for i in 1..=5 {
854            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 3);
855        }
856        assert!(cache.get("e1").is_none());
857
858        let result = cache.upsert_bounded("e1".to_string(), json!({"score": 100}), 3);
859        assert_eq!(result, UpsertResult::Inserted { position: 0 });
860        assert_eq!(keys(&mut cache), ["e1", "e5", "e4"]);
861        assert_eq!(cache.len(), 3);
862    }
863
864    #[test]
865    fn upsert_below_full_cap_is_evicted_immediately() {
866        let mut cache = SortedViewCache::new(
867            "test/top".to_string(),
868            vec!["score".to_string()],
869            SortOrder::Desc,
870        );
871
872        for i in 10..=12 {
873            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 3);
874        }
875        let result = cache.upsert_bounded("low".to_string(), json!({"score": 1}), 3);
876
877        assert_eq!(result, UpsertResult::Inserted { position: 3 });
878        assert!(cache.get("low").is_none());
879        assert_eq!(keys(&mut cache), ["e12", "e11", "e10"]);
880    }
881
882    #[test]
883    fn trim_keeps_keys_cache_and_entities_consistent() {
884        let mut cache = SortedViewCache::new(
885            "test/top".to_string(),
886            vec!["score".to_string()],
887            SortOrder::Desc,
888        );
889
890        for i in 1..=10 {
891            cache.upsert(format!("e{i}"), json!({"score": i}));
892        }
893        // Build the keys cache so the trim takes the in-place truncate path.
894        assert_eq!(cache.ordered_keys().len(), 10);
895
896        assert_eq!(cache.trim_to_max_entries(4), 6);
897        assert_eq!(cache.trim_to_max_entries(4), 0);
898        assert_eq!(cache.len(), 4);
899        assert_eq!(keys(&mut cache), ["e10", "e9", "e8", "e7"]);
900        assert_eq!(cache.get_all_ordered().len(), 4);
901        assert_eq!(cache.remove("e7"), Some(3));
902        assert_eq!(keys(&mut cache), ["e10", "e9", "e8"]);
903    }
904
905    #[test]
906    fn test_nested_sort_field() {
907        let mut cache = SortedViewCache::new(
908            "test/latest".to_string(),
909            vec!["id".to_string(), "round_id".to_string()],
910            SortOrder::Desc,
911        );
912
913        cache.upsert("a".to_string(), json!({"id": {"round_id": 1}}));
914        cache.upsert("b".to_string(), json!({"id": {"round_id": 3}}));
915        cache.upsert("c".to_string(), json!({"id": {"round_id": 2}}));
916
917        let keys = cache.ordered_keys();
918        assert_eq!(keys, vec!["b", "c", "a"]);
919    }
920
921    #[test]
922    fn test_nested_decimal_string_sort_field() {
923        let mut cache = SortedViewCache::new(
924            "test/latest".to_string(),
925            vec!["id".to_string(), "round_id".to_string()],
926            SortOrder::Desc,
927        );
928
929        cache.upsert("9".to_string(), json!({"id": {"round_id": "9"}}));
930        cache.upsert("100".to_string(), json!({"id": {"round_id": "100"}}));
931        cache.upsert("10".to_string(), json!({"id": {"round_id": "10"}}));
932
933        assert_eq!(cache.ordered_keys(), vec!["100", "10", "9"]);
934    }
935
936    #[test]
937    fn test_descending_string_sort_field() {
938        let mut cache = SortedViewCache::new(
939            "test/latest".to_string(),
940            vec!["name".to_string()],
941            SortOrder::Desc,
942        );
943
944        cache.upsert("a".to_string(), json!({"name": "alpha"}));
945        cache.upsert("c".to_string(), json!({"name": "charlie"}));
946        cache.upsert("b".to_string(), json!({"name": "bravo"}));
947
948        assert_eq!(cache.ordered_keys(), vec!["c", "b", "a"]);
949    }
950
951    #[test]
952    fn test_update_with_missing_sort_field_preserves_position() {
953        let mut cache = SortedViewCache::new(
954            "test/latest".to_string(),
955            vec!["id".to_string(), "round_id".to_string()],
956            SortOrder::Desc,
957        );
958
959        cache.upsert(
960            "100".to_string(),
961            json!({"id": {"round_id": 100}, "data": "initial"}),
962        );
963        cache.upsert(
964            "200".to_string(),
965            json!({"id": {"round_id": 200}, "data": "initial"}),
966        );
967        cache.upsert(
968            "300".to_string(),
969            json!({"id": {"round_id": 300}, "data": "initial"}),
970        );
971
972        assert_eq!(cache.ordered_keys(), vec!["300", "200", "100"]);
973
974        cache.upsert("200".to_string(), json!({"data": "updated_without_id"}));
975
976        assert_eq!(
977            cache.ordered_keys(),
978            vec!["300", "200", "100"],
979            "Entity 200 should retain its position even when updated without sort field"
980        );
981
982        let entity = cache.get("200").unwrap();
983        assert_eq!(entity["data"], "updated_without_id");
984    }
985
986    #[test]
987    fn test_new_entity_with_missing_sort_field_sorts_last() {
988        for order in [SortOrder::Desc, SortOrder::Asc] {
989            let mut cache = SortedViewCache::new(
990                "test/latest".to_string(),
991                vec!["id".to_string(), "round_id".to_string()],
992                order,
993            );
994
995            cache.upsert("100".to_string(), json!({"id": {"round_id": 100}}));
996            cache.upsert("200".to_string(), json!({"id": {"round_id": 200}}));
997            cache.upsert("new".to_string(), json!({"data": "no_sort_field"}));
998            cache.upsert("nil".to_string(), json!({"id": {"round_id": null}}));
999
1000            let ranked = match order {
1001                SortOrder::Desc => ["200", "100"],
1002                SortOrder::Asc => ["100", "200"],
1003            };
1004            assert_eq!(
1005                cache.ordered_keys(),
1006                [ranked[0], ranked[1], "new", "nil"],
1007                "{order:?}: an entity without a sort value never outranks one with it; \
1008                 among themselves they order by key"
1009            );
1010        }
1011    }
1012
1013    /// The live `latest` failure: a window full of ranked entities must not
1014    /// admit an unranked one, since it would evict a real member.
1015    #[test]
1016    fn a_full_window_does_not_admit_an_entity_without_a_sort_value() {
1017        let mut cache = SortedViewCache::new(
1018            "test/latest".to_string(),
1019            vec!["id".to_string(), "round_id".to_string()],
1020            SortOrder::Desc,
1021        );
1022        for round in [100, 200, 300] {
1023            cache.upsert_bounded(round.to_string(), json!({"id": {"round_id": round}}), 3);
1024        }
1025        let partial = json!({"metrics": {"checkpoint_count": 7}});
1026        assert!(!cache.would_keep("1", &partial, 3));
1027        cache.upsert_bounded("1".to_string(), partial, 3);
1028        assert_eq!(cache.ordered_keys(), ["300", "200", "100"]);
1029    }
1030}