photon_backend/checkpoint/
coalescer.rs1use 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
21pub 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 #[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 pub fn attach_reclaimer(&self, reclaimer: Arc<dyn PartitionReclaim>) {
70 let _ = self.reclaimer.set(reclaimer);
71 }
72
73 #[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 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 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 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}