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