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. Taken out rather than copied: every
225        // branch below puts the merged entity back.
226        if let Some((old_sort_key, old_entity)) = self.entities.remove(&entity_key) {
227            let effective_sort_value = if matches!(sort_value, SortValue::Null)
228                && !matches!(old_sort_key.sort_value, SortValue::Null)
229            {
230                old_sort_key.sort_value.clone()
231            } else {
232                sort_value
233            };
234
235            let new_sort_key = SortKey {
236                sort_value: effective_sort_value,
237                entity_key: entity_key.clone(),
238                order: self.order,
239            };
240
241            // Merge incoming entity with existing to preserve fields not in the update
242            let merged_entity = Self::deep_merge(old_entity, entity);
243
244            if old_sort_key == new_sort_key {
245                // Sort key unchanged - just update entity data
246                self.entities
247                    .insert(entity_key.clone(), (new_sort_key, merged_entity));
248                // Position unchanged, no structural change
249                let position = self.find_position(&entity_key);
250                return UpsertResult::Updated { position };
251            }
252
253            // Sort key changed - need to reposition
254            self.sorted.remove(&old_sort_key);
255            self.sorted.insert(new_sort_key.clone(), ());
256            self.entities
257                .insert(entity_key.clone(), (new_sort_key, merged_entity));
258            self.cache_dirty = true;
259
260            let position = self.find_position(&entity_key);
261            return UpsertResult::Inserted { position };
262        }
263
264        let new_sort_key = SortKey {
265            sort_value,
266            entity_key: entity_key.clone(),
267            order: self.order,
268        };
269
270        self.sorted.insert(new_sort_key.clone(), ());
271        self.entities
272            .insert(entity_key.clone(), (new_sort_key, entity));
273        self.cache_dirty = true;
274
275        let position = self.find_position(&entity_key);
276
277        UpsertResult::Inserted { position }
278    }
279
280    /// Whether upserting `entity` under `entity_key` into a cache bounded at
281    /// `max_entries` would keep it: the cache already holds the key, has
282    /// room, or the entity sorts before its current last entry.
283    ///
284    /// Lets a caller skip copying an entity that [`Self::upsert_bounded`]
285    /// would evict straight away, which in a busy view is most of them.
286    pub fn would_keep(&self, entity_key: &str, entity: &Value, max_entries: usize) -> bool {
287        if self.entities.contains_key(entity_key) || self.sorted.len() < max_entries {
288            return true;
289        }
290        let Some((last, ())) = self.sorted.last_key_value() else {
291            return true;
292        };
293        let candidate = SortKey {
294            sort_value: self.extract_sort_value(entity),
295            entity_key: entity_key.to_string(),
296            order: self.order,
297        };
298        candidate < *last
299    }
300
301    /// Upsert an entity, then evict from the bottom of the sort order so the
302    /// cache holds at most `max_entries` entities.
303    ///
304    /// If the upserted entity itself sorts beyond `max_entries` it is evicted
305    /// immediately and the returned position is `>= max_entries`.
306    pub fn upsert_bounded(
307        &mut self,
308        entity_key: String,
309        entity: Value,
310        max_entries: usize,
311    ) -> UpsertResult {
312        let result = self.upsert(entity_key, entity);
313        self.trim_to_max_entries(max_entries);
314        result
315    }
316
317    /// Evict entities from the bottom of the sort order until at most
318    /// `max_entries` remain. Returns the number of evicted entities.
319    ///
320    /// Each eviction is an `O(log n)` pop from the end of the ordered index,
321    /// so a batch of evictions (e.g. after a bulk rebuild) costs no more than
322    /// the inserts that caused it. The ordered-keys cache is truncated in place
323    /// when it is current, so trimming does not force a full rebuild.
324    pub fn trim_to_max_entries(&mut self, max_entries: usize) -> usize {
325        let mut evicted = 0;
326        while self.sorted.len() > max_entries {
327            let Some((sort_key, ())) = self.sorted.pop_last() else {
328                break;
329            };
330            self.entities.remove(&sort_key.entity_key);
331            evicted += 1;
332        }
333        if evicted > 0 && !self.cache_dirty {
334            // Only the tail of the order was removed, so the prefix is still
335            // exact.
336            self.keys_cache.truncate(self.sorted.len());
337        }
338        evicted
339    }
340
341    fn deep_merge(base: Value, patch: Value) -> Value {
342        match (base, patch) {
343            (Value::Object(mut base_map), Value::Object(patch_map)) => {
344                for (key, patch_value) in patch_map {
345                    if let Some(base_value) = base_map.remove(&key) {
346                        base_map.insert(key, Self::deep_merge(base_value, patch_value));
347                    } else {
348                        base_map.insert(key, patch_value);
349                    }
350                }
351                Value::Object(base_map)
352            }
353            (_, patch) => patch,
354        }
355    }
356
357    /// Remove an entity, returns the position it was at
358    pub fn remove(&mut self, entity_key: &str) -> Option<usize> {
359        if let Some((sort_key, _)) = self.entities.remove(entity_key) {
360            let position = self.find_position_by_sort_key(&sort_key);
361            self.sorted.remove(&sort_key);
362            self.cache_dirty = true;
363            Some(position)
364        } else {
365            None
366        }
367    }
368
369    /// Get entity by key
370    pub fn get(&self, entity_key: &str) -> Option<&Value> {
371        self.entities.get(entity_key).map(|(_, v)| v)
372    }
373
374    /// Get ordered keys (rebuilds cache if dirty)
375    pub fn ordered_keys(&mut self) -> &[String] {
376        if self.cache_dirty {
377            self.rebuild_keys_cache();
378        }
379        &self.keys_cache
380    }
381
382    /// Get a window of entities
383    pub fn get_window(&mut self, skip: usize, take: usize) -> Vec<(String, Value)> {
384        if self.cache_dirty {
385            self.rebuild_keys_cache();
386        }
387
388        self.keys_cache
389            .iter()
390            .skip(skip)
391            .take(take)
392            .filter_map(|key| {
393                self.entities
394                    .get(key)
395                    .map(|(_, v)| (key.clone(), v.clone()))
396            })
397            .collect()
398    }
399
400    /// Get every entity in deterministic sort order for query-side filtering.
401    pub fn get_all_ordered(&mut self) -> Vec<(String, Value)> {
402        if self.cache_dirty {
403            self.rebuild_keys_cache();
404        }
405
406        self.keys_cache
407            .iter()
408            .filter_map(|key| {
409                self.entities
410                    .get(key)
411                    .map(|(_, value)| (key.clone(), value.clone()))
412            })
413            .collect()
414    }
415
416    /// Compute deltas for a client with a specific window
417    pub fn compute_window_deltas(
418        &mut self,
419        old_window_keys: &[String],
420        skip: usize,
421        take: usize,
422    ) -> Vec<ViewDelta> {
423        if self.cache_dirty {
424            self.rebuild_keys_cache();
425        }
426
427        let new_window_keys: Vec<&String> = self.keys_cache.iter().skip(skip).take(take).collect();
428
429        let old_set: std::collections::HashSet<&String> = old_window_keys.iter().collect();
430        let new_set: std::collections::HashSet<&String> = new_window_keys.iter().cloned().collect();
431
432        let mut deltas = Vec::new();
433
434        // Removed from window
435        for key in old_set.difference(&new_set) {
436            deltas.push(ViewDelta::Remove {
437                key: (*key).clone(),
438            });
439        }
440
441        // Added to window
442        for key in new_set.difference(&old_set) {
443            if let Some((_, entity)) = self.entities.get(*key) {
444                deltas.push(ViewDelta::Add {
445                    key: (*key).clone(),
446                    entity: entity.clone(),
447                });
448            }
449        }
450
451        deltas
452    }
453
454    fn extract_sort_value(&self, entity: &Value) -> SortValue {
455        let mut current = entity;
456        for segment in &self.sort_field {
457            match current.get(segment) {
458                Some(v) => current = v,
459                None => return SortValue::Null,
460            }
461        }
462
463        value_to_sort_value(current)
464    }
465
466    fn find_position(&self, entity_key: &str) -> usize {
467        if let Some((sort_key, _)) = self.entities.get(entity_key) {
468            self.find_position_by_sort_key(sort_key)
469        } else {
470            0
471        }
472    }
473
474    fn find_position_by_sort_key(&self, sort_key: &SortKey) -> usize {
475        self.sorted.range(..sort_key).count()
476    }
477
478    fn rebuild_keys_cache(&mut self) {
479        self.keys_cache = self.sorted.keys().map(|sk| sk.entity_key.clone()).collect();
480        self.cache_dirty = false;
481    }
482}
483
484/// Result of an upsert operation
485#[derive(Debug, Clone, PartialEq)]
486pub enum UpsertResult {
487    /// Entity was inserted at a new position
488    Inserted { position: usize },
489    /// Entity was updated (may or may not have moved)
490    Updated { position: usize },
491}
492
493fn value_to_sort_value(v: &Value) -> SortValue {
494    match v {
495        Value::Null => SortValue::Null,
496        Value::Bool(b) => SortValue::Bool(*b),
497        Value::Number(n) => {
498            if let Some(i) = n.as_i64() {
499                SortValue::Integer(i)
500            } else if let Some(f) = n.as_f64() {
501                SortValue::Float(OrderedFloat(f))
502            } else {
503                SortValue::Null
504            }
505        }
506        Value::String(s) => SortValue::String(s.clone()),
507        _ => SortValue::Null,
508    }
509}
510
511fn compare_decimal_strings(left: &str, right: &str) -> Option<Ordering> {
512    fn parts(value: &str) -> Option<(bool, &str)> {
513        let (negative, digits) = match value.strip_prefix('-') {
514            Some(digits) => (true, digits),
515            None => (false, value),
516        };
517        if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
518            return None;
519        }
520
521        let digits = digits.trim_start_matches('0');
522        let digits = if digits.is_empty() { "0" } else { digits };
523        Some((negative && digits != "0", digits))
524    }
525
526    let (left_negative, left_digits) = parts(left)?;
527    let (right_negative, right_digits) = parts(right)?;
528
529    match (left_negative, right_negative) {
530        (true, false) => Some(Ordering::Less),
531        (false, true) => Some(Ordering::Greater),
532        _ => {
533            let magnitude = left_digits
534                .len()
535                .cmp(&right_digits.len())
536                .then_with(|| left_digits.cmp(right_digits));
537            Some(if left_negative {
538                magnitude.reverse()
539            } else {
540                magnitude
541            })
542        }
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use serde_json::json;
550
551    #[test]
552    fn test_sorted_cache_basic() {
553        let mut cache = SortedViewCache::new(
554            "test/latest".to_string(),
555            vec!["id".to_string()],
556            SortOrder::Desc,
557        );
558
559        cache.upsert("a".to_string(), json!({"id": 1, "name": "first"}));
560        cache.upsert("b".to_string(), json!({"id": 3, "name": "third"}));
561        cache.upsert("c".to_string(), json!({"id": 2, "name": "second"}));
562
563        let keys = cache.ordered_keys();
564        // Desc order: 3, 2, 1
565        assert_eq!(keys, vec!["b", "c", "a"]);
566    }
567
568    #[test]
569    fn test_sorted_cache_window() {
570        let mut cache = SortedViewCache::new(
571            "test/latest".to_string(),
572            vec!["id".to_string()],
573            SortOrder::Desc,
574        );
575
576        for i in 1..=10 {
577            cache.upsert(format!("e{}", i), json!({"id": i}));
578        }
579
580        // Desc order: 10, 9, 8, 7, 6, 5, 4, 3, 2, 1
581        let window = cache.get_window(0, 3);
582        assert_eq!(window.len(), 3);
583        assert_eq!(window[0].0, "e10");
584        assert_eq!(window[1].0, "e9");
585        assert_eq!(window[2].0, "e8");
586
587        let window = cache.get_window(3, 3);
588        assert_eq!(window[0].0, "e7");
589    }
590
591    #[test]
592    fn all_ordered_preserves_stable_sort_and_tie_breaking() {
593        let mut cache = SortedViewCache::new(
594            "test/latest".to_string(),
595            vec!["score".to_string()],
596            SortOrder::Desc,
597        );
598        cache.upsert("b".to_string(), json!({"score": 10}));
599        cache.upsert("a".to_string(), json!({"score": 10}));
600        cache.upsert("c".to_string(), json!({"score": 9}));
601
602        let keys: Vec<_> = cache
603            .get_all_ordered()
604            .into_iter()
605            .map(|(key, _)| key)
606            .collect();
607        assert_eq!(keys, ["a", "b", "c"]);
608    }
609
610    #[test]
611    fn test_sorted_cache_update_moves_position() {
612        let mut cache = SortedViewCache::new(
613            "test/latest".to_string(),
614            vec!["score".to_string()],
615            SortOrder::Desc,
616        );
617
618        cache.upsert("a".to_string(), json!({"score": 10}));
619        cache.upsert("b".to_string(), json!({"score": 20}));
620        cache.upsert("c".to_string(), json!({"score": 15}));
621
622        // Order: b(20), c(15), a(10)
623        assert_eq!(cache.ordered_keys(), vec!["b", "c", "a"]);
624
625        // Update a to have highest score
626        cache.upsert("a".to_string(), json!({"score": 25}));
627
628        // New order: a(25), b(20), c(15)
629        assert_eq!(cache.ordered_keys(), vec!["a", "b", "c"]);
630    }
631
632    #[test]
633    fn test_sorted_cache_remove() {
634        let mut cache = SortedViewCache::new(
635            "test/latest".to_string(),
636            vec!["id".to_string()],
637            SortOrder::Asc,
638        );
639
640        cache.upsert("a".to_string(), json!({"id": 1}));
641        cache.upsert("b".to_string(), json!({"id": 2}));
642        cache.upsert("c".to_string(), json!({"id": 3}));
643
644        assert_eq!(cache.len(), 3);
645
646        let pos = cache.remove("b");
647        assert_eq!(pos, Some(1));
648        assert_eq!(cache.len(), 2);
649        assert_eq!(cache.ordered_keys(), vec!["a", "c"]);
650    }
651
652    #[test]
653    fn test_compute_window_deltas() {
654        let mut cache = SortedViewCache::new(
655            "test/latest".to_string(),
656            vec!["id".to_string()],
657            SortOrder::Desc,
658        );
659
660        // Initial: 5, 4, 3, 2, 1
661        for i in 1..=5 {
662            cache.upsert(format!("e{}", i), json!({"id": i}));
663        }
664
665        let old_window: Vec<String> = vec!["e5".to_string(), "e4".to_string(), "e3".to_string()];
666
667        // Add e6 (new top)
668        cache.upsert("e6".to_string(), json!({"id": 6}));
669
670        // New order: 6, 5, 4, 3, 2, 1
671        // New top 3: e6, e5, e4
672        let deltas = cache.compute_window_deltas(&old_window, 0, 3);
673
674        assert_eq!(deltas.len(), 2);
675        // e3 removed from window
676        assert!(deltas
677            .iter()
678            .any(|d| matches!(d, ViewDelta::Remove { key } if key == "e3")));
679        // e6 added to window
680        assert!(deltas
681            .iter()
682            .any(|d| matches!(d, ViewDelta::Add { key, .. } if key == "e6")));
683    }
684
685    fn keys(cache: &mut SortedViewCache) -> Vec<String> {
686        cache.ordered_keys().to_vec()
687    }
688
689    #[test]
690    fn bounded_upsert_evicts_bottom_of_desc_order() {
691        let mut cache = SortedViewCache::new(
692            "test/top".to_string(),
693            vec!["score".to_string()],
694            SortOrder::Desc,
695        );
696
697        for i in 1..=10 {
698            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 4);
699            assert!(cache.len() <= 4);
700        }
701
702        assert_eq!(cache.len(), 4);
703        assert_eq!(keys(&mut cache), ["e10", "e9", "e8", "e7"]);
704        assert!(cache.get("e1").is_none());
705        assert!(cache.get("e6").is_none());
706    }
707
708    #[test]
709    fn would_keep_matches_what_a_bounded_upsert_keeps() {
710        let mut cache = SortedViewCache::new(
711            "test/top".to_string(),
712            vec!["score".to_string()],
713            SortOrder::Desc,
714        );
715        for i in 1..=10 {
716            let entity = json!({"score": i});
717            let expected = cache.would_keep(&format!("e{i}"), &entity, 4);
718            cache.upsert_bounded(format!("e{i}"), entity, 4);
719            assert!(expected, "an entity above a full cache's tail is kept");
720        }
721        // e7 is the tail of [e10, e9, e8, e7].
722        for (key, score, kept) in [("e0", 0, false), ("e6", 6, false), ("e11", 11, true)] {
723            let entity = json!({"score": score});
724            assert_eq!(cache.would_keep(key, &entity, 4), kept, "{key}");
725            let mut copy = SortedViewCache::new(
726                "test/top".to_string(),
727                vec!["score".to_string()],
728                SortOrder::Desc,
729            );
730            for existing in keys(&mut cache) {
731                let value = cache.get(&existing).unwrap().clone();
732                copy.upsert_bounded(existing, value, 4);
733            }
734            copy.upsert_bounded(key.to_string(), entity, 4);
735            assert_eq!(copy.get(key).is_some(), kept, "{key}");
736        }
737        // A held entity is always kept, even when its new value sorts last.
738        assert!(cache.would_keep("e8", &json!({"score": -1}), 4));
739    }
740
741    #[test]
742    fn bounded_upsert_evicts_bottom_of_asc_order() {
743        let mut cache = SortedViewCache::new(
744            "test/bottom".to_string(),
745            vec!["score".to_string()],
746            SortOrder::Asc,
747        );
748
749        for i in (1..=10).rev() {
750            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 4);
751            assert!(cache.len() <= 4);
752        }
753
754        assert_eq!(cache.len(), 4);
755        assert_eq!(keys(&mut cache), ["e1", "e2", "e3", "e4"]);
756        assert!(cache.get("e10").is_none());
757    }
758
759    #[test]
760    fn bounded_upsert_does_not_evict_stale_top_entities() {
761        let mut cache = SortedViewCache::new(
762            "test/top".to_string(),
763            vec!["score".to_string()],
764            SortOrder::Desc,
765        );
766
767        // The leader is inserted first and never updated again; recency-based
768        // eviction would drop it.
769        cache.upsert_bounded("leader".to_string(), json!({"score": 1_000}), 3);
770        for i in 1..=20 {
771            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 3);
772        }
773
774        assert_eq!(keys(&mut cache), ["leader", "e20", "e19"]);
775    }
776
777    #[test]
778    fn windows_within_cap_match_unbounded_cache() {
779        let mut bounded = SortedViewCache::new(
780            "test/top".to_string(),
781            vec!["score".to_string()],
782            SortOrder::Desc,
783        );
784        let mut unbounded = SortedViewCache::new(
785            "test/top".to_string(),
786            vec!["score".to_string()],
787            SortOrder::Desc,
788        );
789
790        // Scores arrive out of order within each round and every entity moves
791        // up on each later round. Entities never move down, so nothing
792        // evicted can belong back inside the cap (see the edge-case test
793        // below for what happens when they do).
794        for i in 0..200u64 {
795            let key = format!("e{}", i % 60);
796            let score = (i / 60) * 1_000 + (i * 37) % 101;
797            let entity = json!({"score": score, "n": i});
798            bounded.upsert_bounded(key.clone(), entity.clone(), 25);
799            unbounded.upsert(key, entity);
800        }
801
802        assert_eq!(bounded.len(), 25);
803        for (skip, take) in [(0, 25), (0, 10), (5, 20), (24, 1)] {
804            assert_eq!(
805                bounded.get_window(skip, take),
806                unbounded.get_window(skip, take),
807                "window skip={skip} take={take}"
808            );
809        }
810    }
811
812    #[test]
813    fn evicted_entity_is_missing_after_top_moves_down_until_it_updates() {
814        let mut cache = SortedViewCache::new(
815            "test/top".to_string(),
816            vec!["score".to_string()],
817            SortOrder::Desc,
818        );
819
820        for i in 1..=4 {
821            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 3);
822        }
823        assert_eq!(keys(&mut cache), ["e4", "e3", "e2"]);
824
825        // The leader drops to the bottom; e1 would now rank third but was
826        // evicted and has not updated, so e4 holds third place instead.
827        cache.upsert_bounded("e4".to_string(), json!({"score": 0}), 3);
828        assert_eq!(keys(&mut cache), ["e3", "e2", "e4"]);
829
830        // Once e1 updates it re-enters at its correct position.
831        cache.upsert_bounded("e1".to_string(), json!({"score": 1}), 3);
832        assert_eq!(keys(&mut cache), ["e3", "e2", "e1"]);
833    }
834
835    #[test]
836    fn evicted_entity_reenters_when_upserted_again() {
837        let mut cache = SortedViewCache::new(
838            "test/top".to_string(),
839            vec!["score".to_string()],
840            SortOrder::Desc,
841        );
842
843        for i in 1..=5 {
844            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 3);
845        }
846        assert!(cache.get("e1").is_none());
847
848        let result = cache.upsert_bounded("e1".to_string(), json!({"score": 100}), 3);
849        assert_eq!(result, UpsertResult::Inserted { position: 0 });
850        assert_eq!(keys(&mut cache), ["e1", "e5", "e4"]);
851        assert_eq!(cache.len(), 3);
852    }
853
854    #[test]
855    fn upsert_below_full_cap_is_evicted_immediately() {
856        let mut cache = SortedViewCache::new(
857            "test/top".to_string(),
858            vec!["score".to_string()],
859            SortOrder::Desc,
860        );
861
862        for i in 10..=12 {
863            cache.upsert_bounded(format!("e{i}"), json!({"score": i}), 3);
864        }
865        let result = cache.upsert_bounded("low".to_string(), json!({"score": 1}), 3);
866
867        assert_eq!(result, UpsertResult::Inserted { position: 3 });
868        assert!(cache.get("low").is_none());
869        assert_eq!(keys(&mut cache), ["e12", "e11", "e10"]);
870    }
871
872    #[test]
873    fn trim_keeps_keys_cache_and_entities_consistent() {
874        let mut cache = SortedViewCache::new(
875            "test/top".to_string(),
876            vec!["score".to_string()],
877            SortOrder::Desc,
878        );
879
880        for i in 1..=10 {
881            cache.upsert(format!("e{i}"), json!({"score": i}));
882        }
883        // Build the keys cache so the trim takes the in-place truncate path.
884        assert_eq!(cache.ordered_keys().len(), 10);
885
886        assert_eq!(cache.trim_to_max_entries(4), 6);
887        assert_eq!(cache.trim_to_max_entries(4), 0);
888        assert_eq!(cache.len(), 4);
889        assert_eq!(keys(&mut cache), ["e10", "e9", "e8", "e7"]);
890        assert_eq!(cache.get_all_ordered().len(), 4);
891        assert_eq!(cache.remove("e7"), Some(3));
892        assert_eq!(keys(&mut cache), ["e10", "e9", "e8"]);
893    }
894
895    #[test]
896    fn test_nested_sort_field() {
897        let mut cache = SortedViewCache::new(
898            "test/latest".to_string(),
899            vec!["id".to_string(), "round_id".to_string()],
900            SortOrder::Desc,
901        );
902
903        cache.upsert("a".to_string(), json!({"id": {"round_id": 1}}));
904        cache.upsert("b".to_string(), json!({"id": {"round_id": 3}}));
905        cache.upsert("c".to_string(), json!({"id": {"round_id": 2}}));
906
907        let keys = cache.ordered_keys();
908        assert_eq!(keys, vec!["b", "c", "a"]);
909    }
910
911    #[test]
912    fn test_nested_decimal_string_sort_field() {
913        let mut cache = SortedViewCache::new(
914            "test/latest".to_string(),
915            vec!["id".to_string(), "round_id".to_string()],
916            SortOrder::Desc,
917        );
918
919        cache.upsert("9".to_string(), json!({"id": {"round_id": "9"}}));
920        cache.upsert("100".to_string(), json!({"id": {"round_id": "100"}}));
921        cache.upsert("10".to_string(), json!({"id": {"round_id": "10"}}));
922
923        assert_eq!(cache.ordered_keys(), vec!["100", "10", "9"]);
924    }
925
926    #[test]
927    fn test_descending_string_sort_field() {
928        let mut cache = SortedViewCache::new(
929            "test/latest".to_string(),
930            vec!["name".to_string()],
931            SortOrder::Desc,
932        );
933
934        cache.upsert("a".to_string(), json!({"name": "alpha"}));
935        cache.upsert("c".to_string(), json!({"name": "charlie"}));
936        cache.upsert("b".to_string(), json!({"name": "bravo"}));
937
938        assert_eq!(cache.ordered_keys(), vec!["c", "b", "a"]);
939    }
940
941    #[test]
942    fn test_update_with_missing_sort_field_preserves_position() {
943        let mut cache = SortedViewCache::new(
944            "test/latest".to_string(),
945            vec!["id".to_string(), "round_id".to_string()],
946            SortOrder::Desc,
947        );
948
949        cache.upsert(
950            "100".to_string(),
951            json!({"id": {"round_id": 100}, "data": "initial"}),
952        );
953        cache.upsert(
954            "200".to_string(),
955            json!({"id": {"round_id": 200}, "data": "initial"}),
956        );
957        cache.upsert(
958            "300".to_string(),
959            json!({"id": {"round_id": 300}, "data": "initial"}),
960        );
961
962        assert_eq!(cache.ordered_keys(), vec!["300", "200", "100"]);
963
964        cache.upsert("200".to_string(), json!({"data": "updated_without_id"}));
965
966        assert_eq!(
967            cache.ordered_keys(),
968            vec!["300", "200", "100"],
969            "Entity 200 should retain its position even when updated without sort field"
970        );
971
972        let entity = cache.get("200").unwrap();
973        assert_eq!(entity["data"], "updated_without_id");
974    }
975
976    #[test]
977    fn test_new_entity_with_missing_sort_field_gets_null_position() {
978        let mut cache = SortedViewCache::new(
979            "test/latest".to_string(),
980            vec!["id".to_string(), "round_id".to_string()],
981            SortOrder::Desc,
982        );
983
984        cache.upsert("100".to_string(), json!({"id": {"round_id": 100}}));
985        cache.upsert("200".to_string(), json!({"id": {"round_id": 200}}));
986
987        cache.upsert("new".to_string(), json!({"data": "no_sort_field"}));
988
989        let keys = cache.ordered_keys();
990        assert_eq!(
991            keys.first().unwrap(),
992            "new",
993            "New entity without sort field gets Null which sorts first (Null < any value)"
994        );
995    }
996}