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