Skip to main content

krabka_client_streams/store/
session.rs

1//! Session store over the byte backend.
2//!
3//! The keys follow `SessionKeySchema` as `key‖end‖start`, and the values are raw
4//! aggregates. This is the third typed store beside `KeyValueBytesStore` and
5//! `WindowBytesStore`. It supports the JVM session-merge fetch `find_sessions`.
6use std::{
7    any::Any,
8    sync::{Arc, Mutex},
9};
10
11use async_trait::async_trait;
12use bytes::Bytes;
13
14use crate::{
15    processor::{record::RecordContext, serde::Serde},
16    store::{
17        api::StateStore,
18        byte::{ByteKeyValueStore, InMemoryBytes},
19        cache::{named::NamedCache, session::CachingSessionStore},
20        session_schema::{session_end_of, session_key, session_key_bytes_of, session_start_of},
21    },
22};
23
24/// The session store's backing.
25///
26/// The backing is either a plain boxed byte store or a record-cache wrapper over
27/// it. A caller opts into `Cached` with [`SessionBytesStore::enable_cache`].
28/// Uncached stores keep their behavior exactly. This mirrors `kv::Backing`.
29enum Backing {
30    Plain(Box<dyn ByteKeyValueStore>),
31    Cached(CachingSessionStore),
32}
33
34impl Backing {
35    async fn range(&self, lo: &[u8], hi: &[u8]) -> Vec<(Bytes, Bytes)> {
36        match self {
37            Backing::Plain(b) => b.range(lo, hi).await,
38            Backing::Cached(c) => c.range(lo, hi).await,
39        }
40    }
41    async fn scan_all(&self) -> Vec<(Bytes, Bytes)> {
42        match self {
43            Backing::Plain(b) => b.scan_all().await,
44            Backing::Cached(c) => c.scan_all().await,
45        }
46    }
47
48    /// Processing-path write.
49    ///
50    /// `Plain` does a direct put. `Cached` does a write-back put that carries the
51    /// record context, and defers the changelog to flush.
52    async fn put(&mut self, key: Bytes, value: Bytes, ctx: RecordContext) {
53        match self {
54            Backing::Plain(b) => b.put(key, value).await,
55            Backing::Cached(c) => c.put(key, value, ctx).await,
56        }
57    }
58
59    /// Processing-path remove, which writes a tombstone.
60    ///
61    /// `Plain` does a direct delete. `Cached` stages a write-back tombstone that
62    /// carries the context.
63    async fn remove(&mut self, key: Bytes, ctx: RecordContext) {
64        match self {
65            Backing::Plain(b) => {
66                b.delete(&key).await;
67            }
68            Backing::Cached(c) => c.remove(key, ctx).await,
69        }
70    }
71
72    /// Restore-path write that goes below the cache.
73    ///
74    /// This method never stages a dirty entry.
75    async fn apply(&mut self, key: Bytes, value: Option<Bytes>) {
76        match (self, value) {
77            (Backing::Plain(b), Some(v)) => b.put(key, v).await,
78            (Backing::Plain(b), None) => {
79                b.delete(&key).await;
80            }
81            (Backing::Cached(c), Some(v)) => c.put_inner(key, v).await,
82            (Backing::Cached(c), None) => c.delete_inner(&key).await,
83        }
84    }
85
86    async fn clear(&mut self) {
87        match self {
88            Backing::Plain(b) => b.clear().await,
89            Backing::Cached(c) => c.clear().await,
90        }
91    }
92
93    /// Serializes every entry, cache overlay included, as the store's payload.
94    async fn snapshot(&self) -> Bytes {
95        crate::store::snapshot::encode_byte_entries(&self.scan_all().await)
96    }
97
98    /// Replaces every entry with the payload's. The decode runs before the
99    /// wipe, so a malformed payload leaves the store untouched.
100    async fn restore(&mut self, data: &[u8]) -> Result<(), crate::error::StreamsClientError> {
101        let entries = crate::store::snapshot::decode_byte_entries(data)?;
102        self.clear().await;
103        for (key, value) in entries {
104            self.apply(key, Some(value)).await;
105        }
106        Ok(())
107    }
108}
109
110/// Typed session store keyed by `(K, start, end)`.
111///
112/// `find_sessions` returns the merge candidates for a record. Those are the
113/// sessions whose `[start, end]` overlaps the inactivity gap window
114/// `[earliest_end, latest_start]`.
115#[async_trait]
116pub trait SessionStore<K: Send + Sync, V: Send>: StateStore {
117    /// Returns the sessions for `key` with
118    /// `end >= earliest_end && start <= latest_start`.
119    ///
120    /// The result is `(start, end, value)` in store order. Store order is end
121    /// ascending, then start ascending.
122    async fn find_sessions(
123        &self,
124        key: &K,
125        earliest_end: i64,
126        latest_start: i64,
127    ) -> Vec<(i64, i64, V)>;
128    async fn put(&mut self, key: K, start: i64, end: i64, value: V);
129    async fn remove(&mut self, key: &K, start: i64, end: i64);
130    /// Returns every session across ALL keys whose `end <= close_time`.
131    ///
132    /// The result is `(key, start, end, value)`. The emit-final closed-session
133    /// scan uses this method.
134    async fn find_closed_sessions(&self, close_time: i64) -> Vec<(K, i64, i64, V)>;
135}
136
137pub struct SessionBytesStore<K, V> {
138    name: String,
139    changelog_topic: String,
140    backing: Backing,
141    key_serde: Box<dyn Serde<K>>,
142    value_serde: Box<dyn Serde<V>>,
143    changelog: Vec<(Bytes, Option<Bytes>)>,
144    logging: bool,
145    /// Set by [`StateStore::set_record_context`]. The store attaches it to the
146    /// next cached write, so it can forward the deduped `Change` with the right
147    /// context on flush. It is only meaningful when `backing` is `Cached`.
148    pending_ctx: Option<RecordContext>,
149}
150
151impl<K: 'static, V: 'static> SessionBytesStore<K, V> {
152    #[must_use]
153    pub(crate) fn new(
154        name: String,
155        backend: Box<dyn ByteKeyValueStore>,
156        key_serde: Box<dyn Serde<K>>,
157        value_serde: Box<dyn Serde<V>>,
158        changelog_topic: String,
159    ) -> Self {
160        Self {
161            name,
162            changelog_topic,
163            backing: Backing::Plain(backend),
164            key_serde,
165            value_serde,
166            changelog: Vec::new(),
167            logging: true,
168            pending_ctx: None,
169        }
170    }
171
172    #[must_use]
173    pub fn in_memory(
174        name: String,
175        key_serde: Box<dyn Serde<K>>,
176        value_serde: Box<dyn Serde<V>>,
177        changelog_topic: String,
178    ) -> Self {
179        Self::new(
180            name,
181            Box::new(InMemoryBytes::default()),
182            key_serde,
183            value_serde,
184            changelog_topic,
185        )
186    }
187
188    /// Wraps this store's backend in a record cache.
189    ///
190    /// This method moves the backend into a [`CachingSessionStore`]. The caller
191    /// supplies the [`NamedCache`] registered in the task's `ThreadCache`. A
192    /// re-wrap of an already-cached store does nothing.
193    pub(crate) fn enable_cache(&mut self, cache: Arc<Mutex<NamedCache>>) {
194        if !matches!(self.backing, Backing::Plain(_)) {
195            return; // already cached
196        }
197        let placeholder = Backing::Plain(Box::new(InMemoryBytes::default()));
198        let Backing::Plain(backend) = std::mem::replace(&mut self.backing, placeholder) else {
199            unreachable!("guarded by the matches! above")
200        };
201        self.backing = Backing::Cached(CachingSessionStore::with_name(
202            cache,
203            backend,
204            self.name.clone(),
205        ));
206    }
207
208    /// Returns `true` when a record cache wraps this store's backend.
209    #[must_use]
210    pub(crate) fn is_cached(&self) -> bool {
211        matches!(self.backing, Backing::Cached(_))
212    }
213
214    /// Returns the context to stamp on the next cached write.
215    ///
216    /// The context is the stashed
217    /// [`set_record_context`](StateStore::set_record_context) if there is one. If
218    /// not, it is a default rooted at the changelog topic.
219    fn write_ctx(&self) -> RecordContext {
220        self.pending_ctx.clone().unwrap_or(RecordContext {
221            topic: self.changelog_topic.clone(),
222            partition: 0,
223            offset: 0,
224            timestamp: 0,
225        })
226    }
227}
228
229#[async_trait]
230impl<K: Send + 'static, V: Send + 'static> StateStore for SessionBytesStore<K, V> {
231    fn name(&self) -> &str {
232        &self.name
233    }
234    async fn flush(&mut self) {}
235    fn close(&mut self) {}
236    fn as_any_mut(&mut self) -> &mut dyn Any {
237        self
238    }
239    fn changelog_topic(&self) -> &str {
240        &self.changelog_topic
241    }
242    fn take_changelog(&mut self) -> Vec<(Bytes, Option<Bytes>)> {
243        std::mem::take(&mut self.changelog)
244    }
245    async fn apply_changelog(&mut self, key: Bytes, value: Option<Bytes>) {
246        // Restore writes go BELOW the cache (straight to the inner store) so they
247        // don't stage dirty entries that would be re-logged on the next flush.
248        self.backing.apply(key, value).await;
249    }
250    fn set_logging(&mut self, on: bool) {
251        self.logging = on;
252    }
253    fn as_iq(&self) -> Option<&dyn crate::store::iq::IqQueryable> {
254        Some(self)
255    }
256    fn set_record_context(&mut self, ctx: RecordContext) {
257        self.pending_ctx = Some(ctx);
258    }
259    #[allow(private_interfaces)]
260    fn enable_cache_erased(&mut self, cache: Arc<Mutex<NamedCache>>) -> bool {
261        self.enable_cache(cache);
262        true
263    }
264    fn is_cached_erased(&self) -> bool {
265        self.is_cached()
266    }
267    #[allow(private_interfaces)]
268    async fn flush_cache_into(
269        &mut self,
270        buffer: &mut std::collections::VecDeque<(usize, crate::processor::erased::ErasedRecord)>,
271        children: &[usize],
272    ) {
273        use crate::{
274            dsl::{
275                processors::change::Change,
276                windows::{Window, Windowed},
277            },
278            processor::erased::ErasedRecord,
279        };
280        let Backing::Cached(cache) = &self.backing else {
281            return;
282        };
283        // Vec<(session_key_bytes, old, new, ctx)>. Session values are the raw
284        // aggregate bytes (no `ValueAndTimestamp` wrap).
285        let drained = cache.flush_with_old().await;
286        for (sk, old_vb, new_vb, ctx) in drained {
287            // Changelog logs the raw session store-key + the new value, exactly as
288            // the uncached `put` path does.
289            if self.logging {
290                self.changelog.push((sk.clone(), new_vb.clone()));
291            }
292            // The session key is self-contained: it carries both start and end.
293            let session_start = session_start_of(&sk);
294            let session_end = session_end_of(&sk);
295            let key_bytes = session_key_bytes_of(&sk);
296            for &child in children {
297                let key: K = self
298                    .key_serde
299                    .deserialize(&self.changelog_topic, key_bytes)
300                    .expect("flush_cache_into session key deserialize");
301                let old: Option<V> = old_vb.as_ref().map(|b| {
302                    self.value_serde
303                        .deserialize(&self.changelog_topic, b)
304                        .expect("flush_cache_into session old value deserialize")
305                });
306                let new: Option<V> = new_vb.as_ref().map(|b| {
307                    self.value_serde
308                        .deserialize(&self.changelog_topic, b)
309                        .expect("flush_cache_into session new value deserialize")
310                });
311                let windowed = Windowed {
312                    key,
313                    window: Window {
314                        start: session_start,
315                        end: session_end,
316                    },
317                };
318                let change = Change { old, new };
319                buffer.push_back((
320                    child,
321                    ErasedRecord::new(Some(Box::new(windowed)), Box::new(change), ctx.timestamp),
322                ));
323            }
324        }
325    }
326    async fn clear(&mut self) {
327        self.backing.clear().await;
328        self.changelog.clear();
329    }
330    async fn snapshot(&mut self) -> Bytes {
331        self.backing.snapshot().await
332    }
333    async fn restore_snapshot(
334        &mut self,
335        data: Bytes,
336    ) -> Result<(), crate::error::StreamsClientError> {
337        self.backing.restore(&data).await?;
338        // The task rewinds to the cut, so anything logged since is gone.
339        self.changelog.clear();
340        Ok(())
341    }
342}
343
344// `SessionBytesStore` holds only `Box<dyn Serde<_>>` + byte buffers, so it is
345// `Send + Sync` for any `K`/`V` — no `Sync` bound needed on the impl.
346#[async_trait::async_trait]
347impl<K: 'static, V: 'static> crate::store::iq::IqQueryable for SessionBytesStore<K, V> {
348    fn kind(&self) -> crate::store::iq::StoreKind {
349        crate::store::iq::StoreKind::Session
350    }
351    async fn iq_session_fetch_key(&self, key: &[u8]) -> Vec<((i64, i64), bytes::Bytes)> {
352        let lo = session_key(key, 0, 0);
353        let hi = session_key(key, i64::MAX, i64::MAX);
354        let mut out = Vec::new();
355        for (k, raw) in self.backing.range(&lo, &hi).await {
356            if session_key_bytes_of(&k) != key {
357                continue;
358            }
359            let start = session_start_of(&k);
360            let end = session_end_of(&k);
361            out.push(((start, end), bytes::Bytes::copy_from_slice(&raw)));
362        }
363        out
364    }
365}
366
367#[async_trait]
368impl<K: Send + Sync + 'static, V: Send + 'static> SessionStore<K, V> for SessionBytesStore<K, V> {
369    async fn find_sessions(
370        &self,
371        key: &K,
372        earliest_end: i64,
373        latest_start: i64,
374    ) -> Vec<(i64, i64, V)> {
375        let kb = self.key_serde.serialize(&self.changelog_topic, key);
376        // Lower bound: smallest qualifying end (clamped to 0 — stored ends are
377        // non-negative epoch millis; a negative earliest_end means "all qualify").
378        let lo = session_key(&kb, 0, earliest_end.max(0));
379        // Upper bound: past every entry for this key prefix.
380        let hi = session_key(&kb, i64::MAX, i64::MAX);
381        let mut out = Vec::new();
382        for (k, raw) in self.backing.range(&lo, &hi).await {
383            if session_key_bytes_of(&k) != kb.as_ref() {
384                continue; // guard prefix collisions with a different key
385            }
386            let end = session_end_of(&k);
387            let start = session_start_of(&k);
388            if end >= earliest_end && start <= latest_start {
389                out.push((
390                    start,
391                    end,
392                    self.value_serde
393                        .deserialize(&self.changelog_topic, &raw)
394                        .expect("session value deserialize"),
395                ));
396            }
397        }
398        out
399    }
400
401    async fn put(&mut self, key: K, start: i64, end: i64, value: V) {
402        let kb = self.key_serde.serialize(&self.changelog_topic, &key);
403        let sk = session_key(&kb, start, end);
404        let raw = self.value_serde.serialize(&self.changelog_topic, &value);
405        match &self.backing {
406            // Plain: write through + buffer the changelog now (today's behavior).
407            Backing::Plain(_) => {
408                self.backing
409                    .put(sk.clone(), raw.clone(), self.write_ctx())
410                    .await;
411                if self.logging {
412                    self.changelog.push((sk, Some(raw)));
413                }
414            }
415            // Cached: write-back only. The changelog record is deferred to
416            // `flush_cache_into` (the changelog store sits below the cache).
417            Backing::Cached(_) => {
418                let ctx = self.write_ctx();
419                self.backing.put(sk, raw, ctx).await;
420            }
421        }
422    }
423
424    async fn remove(&mut self, key: &K, start: i64, end: i64) {
425        let kb = self.key_serde.serialize(&self.changelog_topic, key);
426        let sk = session_key(&kb, start, end);
427        let cached = matches!(self.backing, Backing::Cached(_));
428        let ctx = self.write_ctx();
429        self.backing.remove(sk.clone(), ctx).await;
430        // Plain logs the tombstone now; cached defers it to flush.
431        if self.logging && !cached {
432            self.changelog.push((sk, None));
433        }
434    }
435
436    async fn find_closed_sessions(&self, close_time: i64) -> Vec<(K, i64, i64, V)> {
437        let mut out = Vec::new();
438        for (k, raw) in self.backing.scan_all().await {
439            let end = session_end_of(&k);
440            if end > close_time {
441                continue;
442            }
443            let key = self
444                .key_serde
445                .deserialize(&self.changelog_topic, session_key_bytes_of(&k))
446                .expect("session key deserialize");
447            let value = self
448                .value_serde
449                .deserialize(&self.changelog_topic, &raw)
450                .expect("session value deserialize");
451            out.push((key, session_start_of(&k), end, value));
452        }
453        out
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::processor::serde::{I64Serde, StringSerde};
461
462    fn store() -> SessionBytesStore<String, i64> {
463        SessionBytesStore::<String, i64>::in_memory(
464            "s".into(),
465            Box::new(StringSerde),
466            Box::new(I64Serde),
467            "app-s-changelog".into(),
468        )
469    }
470
471    #[tokio::test]
472    async fn put_find_remove_and_changelog() {
473        let mut s = store();
474        s.put("k".to_string(), 0, 10, 1).await; // session [0,10]
475        s.put("k".to_string(), 50, 60, 2).await; // session [50,60]
476        // gap=20 around ts=15 → earliest_end=15-20=-5, latest_start=15+20=35 →
477        // only [0,10] qualifies (end 10 >= -5, start 0 <= 35); [50,60] start 50 > 35.
478        let found = s.find_sessions(&"k".to_string(), -5, 35).await;
479        assert_eq!(found, vec![(0, 10, 1)]);
480        // remove [0,10]
481        s.remove(&"k".to_string(), 0, 10).await;
482        assert_eq!(s.find_sessions(&"k".to_string(), -5, 35).await, vec![]);
483        // changelog: put, put, remove → 3 entries (last is a tombstone)
484        let cl = s.take_changelog();
485        assert_eq!(cl.len(), 3);
486        assert!(cl[2].1.is_none());
487    }
488
489    #[tokio::test]
490    async fn find_sessions_returns_store_order_end_then_start() {
491        let mut s = store();
492        s.put("k".to_string(), 0, 30, 1).await;
493        s.put("k".to_string(), 0, 10, 2).await;
494        // both qualify for earliest_end=0, latest_start=100; store order = end asc.
495        let found = s.find_sessions(&"k".to_string(), 0, 100).await;
496        assert_eq!(found, vec![(0, 10, 2), (0, 30, 1)]);
497    }
498
499    #[tokio::test]
500    async fn other_key_prefix_is_not_returned() {
501        let mut s = store();
502        s.put("k".to_string(), 0, 10, 1).await;
503        s.put("kk".to_string(), 0, 10, 9).await; // longer key sharing the "k" prefix
504        let found = s.find_sessions(&"k".to_string(), 0, 100).await;
505        assert_eq!(found, vec![(0, 10, 1)]);
506    }
507
508    #[tokio::test]
509    async fn find_closed_sessions_scans_by_end() {
510        let mut s = SessionBytesStore::<String, i64>::in_memory(
511            "s".into(),
512            Box::new(StringSerde),
513            Box::new(I64Serde),
514            "app-s-changelog".into(),
515        );
516        // (key, start, end, value)
517        s.put("a".into(), 0, 5, 1).await;
518        s.put("b".into(), 2, 8, 2).await;
519        s.put("a".into(), 20, 30, 3).await;
520
521        // end <= 8 → the first two (sort to make order-independent).
522        let mut got = s.find_closed_sessions(8).await;
523        got.sort();
524        assert_eq!(
525            got,
526            vec![("a".to_string(), 0, 5, 1), ("b".to_string(), 2, 8, 2)]
527        );
528        // end <= 4 → none.
529        assert!(s.find_closed_sessions(4).await.is_empty());
530    }
531
532    #[tokio::test]
533    async fn restore_via_changelog_rebuilds_sessions() {
534        let mut s = store();
535        s.put("k".to_string(), 0, 10, 1).await;
536        s.put("k".to_string(), 50, 60, 2).await;
537        s.remove(&"k".to_string(), 0, 10).await; // a tombstone in the changelog
538        let cl = s.take_changelog();
539        // Clean-slate restore: replay the changelog into a fresh store.
540        let mut s2 = store();
541        for (k, v) in cl {
542            s2.apply_changelog(k, v).await;
543        }
544        // [0,10] was removed; only [50,60] survives.
545        assert_eq!(
546            s2.find_sessions(&"k".to_string(), 0, 100).await,
547            vec![(50, 60, 2)]
548        );
549    }
550
551    // ── Record-cache tests (mirror kv.rs) ───────────────────────────────────
552
553    fn cached_store() -> SessionBytesStore<String, i64> {
554        let mut s = store();
555        s.enable_cache(Arc::new(Mutex::new(NamedCache::new("s".into()))));
556        s
557    }
558
559    fn ctx_at(ts: i64) -> RecordContext {
560        RecordContext {
561            topic: "t".into(),
562            partition: 0,
563            offset: 0,
564            timestamp: ts,
565        }
566    }
567
568    #[tokio::test]
569    async fn cached_session_store_reads_your_writes() {
570        let mut s = cached_store();
571        s.set_record_context(ctx_at(0));
572        // Two cached puts to the SAME session key [0, 10].
573        s.put("a".into(), 0, 10, 1).await;
574        s.put("a".into(), 0, 10, 2).await;
575        // Cache-first read sees the latest staged write (value 2).
576        assert_eq!(
577            s.find_sessions(&"a".to_string(), 0, 100).await,
578            vec![(0, 10, 2)]
579        );
580        // No changelog buffered while writes only touch the cache.
581        assert!(s.take_changelog().is_empty());
582    }
583
584    #[tokio::test]
585    async fn flush_cache_into_emits_deduped_session_change() {
586        use crate::dsl::{
587            processors::change::Change,
588            windows::{Window, Windowed},
589        };
590        let mut s = cached_store();
591        // Seed a committed value (flush it through + clear the changelog buffer).
592        s.set_record_context(ctx_at(0));
593        s.put("a".into(), 0, 10, 1).await;
594        let mut seed = std::collections::VecDeque::new();
595        s.flush_cache_into(&mut seed, &[0]).await;
596        let _ = s.take_changelog();
597
598        // Two staged writes for the same session key [0, 10] under context ts=7.
599        s.set_record_context(ctx_at(7));
600        s.put("a".into(), 0, 10, 2).await;
601        s.put("a".into(), 0, 10, 3).await;
602
603        let mut buffer = std::collections::VecDeque::new();
604        s.flush_cache_into(&mut buffer, &[7]).await;
605        assert_eq!(buffer.len(), 1);
606        let (child, rec) = &buffer[0];
607        assert_eq!(*child, 7);
608        assert_eq!(rec.timestamp, 7);
609        // Key downcasts to Windowed<String> carrying the encoded start/end.
610        let key = rec
611            .key
612            .as_ref()
613            .unwrap()
614            .downcast_ref::<Windowed<String>>()
615            .unwrap();
616        assert_eq!(key.key, "a");
617        assert_eq!(key.window, Window { start: 0, end: 10 });
618        // Value downcasts to Change<i64> { old = committed (1), new = latest (3) }.
619        let change = rec.value.downcast_ref::<Change<i64>>().unwrap();
620        assert_eq!(change.old, Some(1));
621        assert_eq!(change.new, Some(3));
622
623        // Changelog buffered the RAW session store-key + the latest value.
624        let cl = s.take_changelog();
625        assert_eq!(cl.len(), 1);
626        assert_eq!(
627            cl[0].0,
628            session_key(
629                &StringSerde.serialize("app-s-changelog", &"a".to_string()),
630                0,
631                10
632            )
633        );
634        assert_eq!(cl[0].1, Some(I64Serde.serialize("app-s-changelog", &3)));
635
636        // Inner store now holds the write-through value.
637        assert_eq!(
638            s.find_sessions(&"a".to_string(), 0, 100).await,
639            vec![(0, 10, 3)]
640        );
641    }
642
643    /// Cached `find_closed_sessions` routes through `Backing::Cached::scan_all`.
644    /// It overlays the cache across all keys, so reads see your writes before
645    /// flush.
646    #[tokio::test]
647    async fn cached_find_closed_sessions_overlays_cache() {
648        let mut s = cached_store();
649        s.set_record_context(ctx_at(0));
650        s.put("a".into(), 0, 5, 1).await;
651        s.put("b".into(), 2, 8, 2).await;
652        s.put("a".into(), 20, 30, 3).await;
653
654        let mut got = s.find_closed_sessions(8).await;
655        got.sort();
656        assert_eq!(
657            got,
658            vec![("a".to_string(), 0, 5, 1), ("b".to_string(), 2, 8, 2)]
659        );
660        assert!(s.find_closed_sessions(4).await.is_empty());
661        // Reads only touched the cache; no changelog buffered yet.
662        assert!(s.take_changelog().is_empty());
663    }
664
665    /// On a cached store, `remove` stages a write-back tombstone that hides the
666    /// inner session and defers its changelog. The flush then emits the deduped
667    /// tombstone Change and the changelog record.
668    #[tokio::test]
669    async fn cached_remove_stages_tombstone_and_flushes() {
670        use crate::dsl::processors::change::Change;
671        let mut s = cached_store();
672        // Seed + flush a committed session [0, 10] = 1.
673        s.set_record_context(ctx_at(0));
674        s.put("a".into(), 0, 10, 1).await;
675        let mut seed = std::collections::VecDeque::new();
676        s.flush_cache_into(&mut seed, &[0]).await;
677        let _ = s.take_changelog();
678
679        // remove stages a tombstone (cached → no immediate changelog).
680        s.set_record_context(ctx_at(7));
681        s.remove(&"a".to_string(), 0, 10).await;
682        assert!(s.take_changelog().is_empty());
683        assert!(s.find_sessions(&"a".to_string(), 0, 100).await.is_empty());
684
685        let mut buffer = std::collections::VecDeque::new();
686        s.flush_cache_into(&mut buffer, &[0]).await;
687        assert_eq!(buffer.len(), 1);
688        let change = buffer[0].1.value.downcast_ref::<Change<i64>>().unwrap();
689        assert_eq!(change.old, Some(1)); // committed value
690        assert_eq!(change.new, None); // tombstone
691        let cl = s.take_changelog();
692        assert_eq!(cl.len(), 1);
693        assert!(cl[0].1.is_none()); // changelog tombstone
694    }
695
696    /// `apply_changelog` on a cached store writes BELOW the cache, so it stages
697    /// no dirty entry and the cache flush is empty. A `None` deletes through.
698    #[tokio::test]
699    async fn cached_apply_changelog_goes_below_cache() {
700        let mut s = cached_store();
701        let sk = session_key(
702            &StringSerde.serialize("app-s-changelog", &"a".to_string()),
703            0,
704            10,
705        );
706        s.apply_changelog(sk.clone(), Some(I64Serde.serialize("app-s-changelog", &9)))
707            .await;
708        assert_eq!(
709            s.find_sessions(&"a".to_string(), 0, 100).await,
710            vec![(0, 10, 9)]
711        );
712        let mut buffer = std::collections::VecDeque::new();
713        s.flush_cache_into(&mut buffer, &[0]).await;
714        assert!(buffer.is_empty());
715        assert!(s.take_changelog().is_empty());
716
717        // None apply deletes through.
718        s.apply_changelog(sk, None).await;
719        assert!(s.find_sessions(&"a".to_string(), 0, 100).await.is_empty());
720    }
721
722    /// `clear` on a cached store empties both the cache layer and inner store.
723    #[tokio::test]
724    async fn cached_clear_empties_everything() {
725        let mut s = cached_store();
726        s.set_record_context(ctx_at(0));
727        s.put("a".into(), 0, 10, 1).await;
728        StateStore::clear(&mut s).await;
729        assert!(s.find_sessions(&"a".to_string(), 0, 100).await.is_empty());
730        assert!(s.find_closed_sessions(i64::MAX).await.is_empty());
731        let mut buffer = std::collections::VecDeque::new();
732        s.flush_cache_into(&mut buffer, &[0]).await;
733        assert!(buffer.is_empty());
734    }
735
736    /// `enable_cache` is idempotent. A re-wrap of an already-cached store does
737    /// nothing.
738    #[tokio::test]
739    async fn enable_cache_is_idempotent() {
740        let mut s = store();
741        assert!(!s.is_cached());
742        s.enable_cache(Arc::new(Mutex::new(NamedCache::new("s".into()))));
743        assert!(s.is_cached());
744        s.enable_cache(Arc::new(Mutex::new(NamedCache::new("s".into()))));
745        assert!(s.is_cached());
746    }
747
748    /// Plain-store lifecycle. `set_logging(false)` suppresses the changelog,
749    /// `flush` and `close` do nothing, and `StateStore::clear` wipes the store
750    /// and its buffered changelog.
751    #[tokio::test]
752    async fn plain_store_lifecycle_logging_flush_close_clear() {
753        let mut s = store();
754        s.set_logging(false);
755        s.put("a".into(), 0, 10, 1).await;
756        assert!(
757            s.take_changelog().is_empty(),
758            "logging off suppresses the changelog"
759        );
760        // Read still works; flush/close are no-ops.
761        assert_eq!(
762            s.find_sessions(&"a".to_string(), 0, 100).await,
763            vec![(0, 10, 1)]
764        );
765        s.flush().await;
766        s.close();
767
768        // Re-enable logging and write, then clear wipes state + changelog.
769        s.set_logging(true);
770        s.put("b".into(), 0, 10, 2).await;
771        StateStore::clear(&mut s).await;
772        assert!(s.find_sessions(&"b".to_string(), 0, 100).await.is_empty());
773        assert!(s.take_changelog().is_empty());
774    }
775
776    #[tokio::test]
777    async fn plain_session_store_unchanged() {
778        let mut s = store();
779        s.put("a".into(), 0, 10, 1).await;
780        assert_eq!(
781            s.find_sessions(&"a".to_string(), 0, 100).await,
782            vec![(0, 10, 1)]
783        );
784        let cl = s.take_changelog();
785        assert_eq!(cl.len(), 1);
786        let mut buffer = std::collections::VecDeque::new();
787        s.flush_cache_into(&mut buffer, &[0]).await;
788        assert!(buffer.is_empty());
789    }
790}