Skip to main content

photon_backend/checkpoint/
coalescer.rs

1//! Coalesced checkpoint persistence (flush on interval / N events).
2
3use std::collections::{HashMap, HashSet};
4use std::sync::Arc;
5use std::sync::OnceLock;
6use std::time::Duration;
7
8use tokio::sync::Mutex;
9use tokio::task::JoinHandle;
10
11use crate::retention::{PartitionReclaim, TopicPartition};
12use crate::storage::StoragePort;
13
14#[derive(Debug, Clone, Hash, PartialEq, Eq)]
15struct CoalesceKey {
16    subscription_name: String,
17    topic_name: String,
18    topic_key: Option<String>,
19}
20
21/// Tracks high-water seq per subscription partition; flushes periodically.
22pub struct CheckpointCoalescer {
23    port: Arc<dyn StoragePort>,
24    pending: Arc<Mutex<HashMap<CoalesceKey, i64>>>,
25    flush_every: u32,
26    reclaimer: Arc<OnceLock<Arc<dyn PartitionReclaim>>>,
27    _task: JoinHandle<()>,
28}
29
30impl CheckpointCoalescer {
31    /// Start a background flush loop bound to `port`.
32    #[must_use]
33    pub fn new(port: Arc<dyn StoragePort>) -> Self {
34        let pending = Arc::new(Mutex::new(HashMap::new()));
35        let reclaimer = Arc::new(OnceLock::new());
36        let flush_every = checkpoint_flush_every();
37        let flush_interval = Duration::from_millis(checkpoint_flush_ms());
38        let task_pending = Arc::clone(&pending);
39        let task_port = Arc::clone(&port);
40        let task_reclaimer = Arc::clone(&reclaimer);
41        let task = tokio::spawn(async move {
42            let mut ticker = tokio::time::interval(flush_interval);
43            loop {
44                ticker.tick().await;
45                let reclaimer = task_reclaimer.get().cloned();
46                if let Err(e) = flush_map(&task_port, &task_pending, reclaimer).await {
47                    tracing::warn!(error = %e, "checkpoint coalescer flush failed");
48                    crate::instrumentation::log_ops(
49                        "checkpoint",
50                        "coalescer_flush",
51                        "checkpoint coalescer flush failed",
52                        "",
53                        "",
54                        &e.to_string(),
55                    );
56                }
57            }
58        });
59        Self {
60            port,
61            pending,
62            flush_every,
63            reclaimer,
64            _task: task,
65        }
66    }
67
68    /// Wire opportunistic reclaim after checkpoint commits.
69    pub fn attach_reclaimer(&self, reclaimer: Arc<dyn PartitionReclaim>) {
70        let _ = self.reclaimer.set(reclaimer);
71    }
72
73    /// Record delivered seq; may trigger immediate flush when batch threshold hit.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error if the operation fails.
78    #[tracing::instrument(
79        name = "photon.checkpoint.record",
80        skip(self),
81        fields(subscription = %subscription_name, topic = %topic_name, seq)
82    )]
83    pub async fn record(
84        &self,
85        subscription_name: &str,
86        topic_name: &str,
87        topic_key: Option<&str>,
88        seq: i64,
89    ) -> crate::Result<()> {
90        let key = CoalesceKey {
91            subscription_name: subscription_name.to_string(),
92            topic_name: topic_name.to_string(),
93            topic_key: topic_key.map(String::from),
94        };
95        let should_flush = {
96            let mut guard = self.pending.lock().await;
97            guard
98                .entry(key)
99                .and_modify(|existing| *existing = (*existing).max(seq))
100                .or_insert(seq);
101            u32::try_from(guard.len()).unwrap_or(u32::MAX) >= self.flush_every
102        };
103        if should_flush {
104            self.flush().await?;
105        }
106        Ok(())
107    }
108
109    /// Flush all pending checkpoints now.
110    ///
111    /// # Errors
112    ///
113    /// Returns an error if the operation fails.
114    pub async fn flush(&self) -> crate::Result<()> {
115        let reclaimer = self.reclaimer.get().cloned();
116        flush_map(&self.port, &self.pending, reclaimer).await
117    }
118
119    /// Minimum unflushed seq for a storage partition.
120    pub async fn pending_min_seq(&self, topic: &str, topic_key: Option<&str>) -> Option<i64> {
121        let guard = self.pending.lock().await;
122        guard
123            .iter()
124            .filter(|(k, _)| k.topic_name == topic && k.topic_key.as_deref() == topic_key)
125            .map(|(_, seq)| *seq)
126            .min()
127    }
128
129    /// Storage partitions with unflushed checkpoint state.
130    pub async fn pending_partitions(&self) -> Vec<TopicPartition> {
131        let keys: Vec<CoalesceKey> = {
132            let guard = self.pending.lock().await;
133            guard.keys().cloned().collect()
134        };
135        let mut seen = HashSet::new();
136        let mut out = Vec::new();
137        for key in keys {
138            let part = TopicPartition::new(key.topic_name, key.topic_key);
139            if seen.insert(part.clone()) {
140                out.push(part);
141            }
142        }
143        out
144    }
145}
146
147#[tracing::instrument(name = "photon.checkpoint.flush", skip_all)]
148async fn flush_map(
149    port: &Arc<dyn StoragePort>,
150    pending: &Mutex<HashMap<CoalesceKey, i64>>,
151    reclaimer: Option<Arc<dyn PartitionReclaim>>,
152) -> crate::Result<()> {
153    let batch: Vec<(CoalesceKey, i64)> = {
154        let mut guard = pending.lock().await;
155        guard.drain().collect()
156    };
157
158    let mut sweep_parts = HashSet::new();
159    for (key, seq) in &batch {
160        port.commit_checkpoint(
161            &key.subscription_name,
162            &key.topic_name,
163            key.topic_key.as_deref(),
164            *seq,
165        )
166        .await?;
167        sweep_parts.insert(TopicPartition::new(
168            key.topic_name.clone(),
169            key.topic_key.clone(),
170        ));
171    }
172
173    if let Some(reclaimer) = reclaimer {
174        let partitions: Vec<TopicPartition> = sweep_parts.into_iter().collect();
175        if !partitions.is_empty() {
176            reclaimer.sweep_partitions(&partitions).await?;
177        }
178    }
179
180    Ok(())
181}
182
183fn checkpoint_flush_every() -> u32 {
184    std::env::var("PHOTON_CHECKPOINT_COALESCE_EVERY")
185        .ok()
186        .and_then(|s| s.parse().ok())
187        .unwrap_or(10)
188        .max(1)
189}
190
191fn checkpoint_flush_ms() -> u64 {
192    std::env::var("PHOTON_CHECKPOINT_FLUSH_MS")
193        .ok()
194        .and_then(|s| s.parse().ok())
195        .unwrap_or(500)
196        .max(50)
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn defaults_are_sane() {
205        assert!(checkpoint_flush_every() >= 1);
206        assert!(checkpoint_flush_ms() >= 50);
207    }
208
209    #[tokio::test]
210    async fn record_keeps_highest_pending_sequence_for_partition() {
211        let port = Arc::new(crate::storage::InProcStoragePort::new(
212            crate::event::TransportCrypto::from_bytes(*b"photon-dev-transport-key-32bytes"),
213        ));
214        let coalescer = CheckpointCoalescer::new(port);
215
216        coalescer
217            .record("sub-a", "orders.created", None, 10)
218            .await
219            .unwrap();
220        coalescer
221            .record("sub-a", "orders.created", None, 5)
222            .await
223            .unwrap();
224
225        assert_eq!(
226            coalescer.pending_min_seq("orders.created", None).await,
227            Some(10)
228        );
229    }
230}