use std::collections::HashMap;
use std::sync::Arc;
use bytes::Bytes;
use dashmap::DashMap;
use fluvio::consumer::{ConsumerConfigExtBuilder, OffsetManagementStrategy};
use fluvio::Offset;
use futures::StreamExt;
use photon_backend::{PhotonError, Result};
use crate::config::FluvioConfig;
use crate::connect::SharedClient;
use crate::publish::PublishPipeline;
use crate::stream_shard::{composite_seq, decompose_seq, pick_shard, publish_routing_key};
use crate::subject::{
checkpoint_key, checkpoint_key_sharded, checkpoint_key_unkeyed_shards,
};
#[derive(Clone)]
pub struct CheckpointStore {
cache: Arc<DashMap<String, Bytes>>,
client: SharedClient,
config: FluvioConfig,
_pipeline: PublishPipeline,
}
impl CheckpointStore {
pub async fn connect(client: SharedClient, config: &FluvioConfig) -> Result<Self> {
crate::topic::ensure_checkpoint_topic(&client, config).await?;
let cache = Arc::new(DashMap::new());
scan_checkpoint_topic(&client, config, Arc::clone(&cache)).await?;
let pipeline = PublishPipeline::new(Arc::clone(&client), config);
Ok(Self {
cache,
client,
config: config.clone(),
_pipeline: pipeline,
})
}
pub fn load(
&self,
subscription_name: &str,
topic_name: &str,
topic_key: Option<&str>,
) -> Result<Option<i64>> {
if !self.config.is_sharded() {
let key = checkpoint_key(subscription_name, topic_name, topic_key);
return load_i64(&self.cache, &key);
}
if let Some(key) = topic_key {
let shard = pick_shard(key, self.config.topic_shards);
let ck = checkpoint_key_sharded(subscription_name, topic_name, Some(key), shard);
let local = load_i64(&self.cache, &ck)?;
return Ok(local.map(|seq| {
composite_seq(shard, u64::try_from(seq.max(0)).unwrap_or(0))
}));
}
let map = self.load_unkeyed_shard_map(subscription_name, topic_name)?;
Ok(max_composite_from_map(&map))
}
pub async fn commit(
&self,
subscription_name: &str,
topic_name: &str,
topic_key: Option<&str>,
last_seq: i64,
) -> Result<()> {
if !self.config.is_sharded() {
let key = checkpoint_key(subscription_name, topic_name, topic_key);
return put_i64(self, &key, last_seq).await;
}
let (shard, local) = decompose_seq(last_seq);
if topic_key.is_some() {
let routing = publish_routing_key(topic_key, "");
let expected = pick_shard(&routing, self.config.topic_shards);
let shard = if self.config.topic_shards > 1 && last_seq < crate::stream_shard::SEQ_STRIDE
{
expected
} else {
shard
};
let key = checkpoint_key_sharded(subscription_name, topic_name, topic_key, shard);
return put_i64(self, &key, local).await;
}
let mut map = self.load_unkeyed_shard_map(subscription_name, topic_name)?;
map.insert(shard, local);
let key = checkpoint_key_unkeyed_shards(subscription_name, topic_name);
put_json_map(self, &key, &map).await
}
pub fn load_unkeyed_shard_map(
&self,
subscription_name: &str,
topic_name: &str,
) -> Result<HashMap<u32, i64>> {
if !self.config.is_sharded() {
let key = checkpoint_key(subscription_name, topic_name, None);
return Ok(load_i64(&self.cache, &key)?
.map(|seq| {
let mut map = HashMap::new();
map.insert(0, seq);
map
})
.unwrap_or_default());
}
let key = checkpoint_key_unkeyed_shards(subscription_name, topic_name);
load_shard_map(&self.cache, &key)
}
}
async fn scan_checkpoint_topic(
client: &SharedClient,
config: &FluvioConfig,
cache: Arc<DashMap<String, Bytes>>,
) -> Result<()> {
let topic = config.checkpoint_topic();
let consumer_config = ConsumerConfigExtBuilder::default()
.topic(topic)
.partition(0)
.offset_start(Offset::beginning())
.offset_strategy(OffsetManagementStrategy::None)
.disable_continuous(true)
.build()
.map_err(|e| PhotonError::Internal(format!("fluvio checkpoint scanner config: {e}")))?;
let mut stream = client
.consumer_with_config(consumer_config)
.await
.map_err(|e| PhotonError::Internal(format!("fluvio checkpoint scanner: {e}")))?;
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if tokio::time::Instant::now() >= deadline {
break;
}
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
match tokio::time::timeout(remaining, stream.next()).await {
Ok(Some(Ok(record))) => {
if let Some(key_data) = record.get_key() {
let key = String::from_utf8_lossy(key_data).into_owned();
let value = record.value();
cache.insert(key, Bytes::copy_from_slice(value));
}
}
Ok(Some(Err(e))) => {
return Err(PhotonError::Internal(format!("fluvio checkpoint scan: {e:?}")));
}
Ok(None) | Err(_) => break,
}
}
Ok(())
}
fn load_i64(cache: &DashMap<String, Bytes>, key: &str) -> Result<Option<i64>> {
cache
.get(key)
.map_or(Ok(None), |bytes| parse_checkpoint_bytes(&bytes).map(Some))
}
async fn put_i64(store: &CheckpointStore, key: &str, value: i64) -> Result<()> {
let payload = Bytes::from(value.to_string());
store.cache.insert(key.to_string(), payload.clone());
produce_checkpoint(store, key, payload).await
}
fn load_shard_map(cache: &DashMap<String, Bytes>, key: &str) -> Result<HashMap<u32, i64>> {
cache
.get(key)
.map_or_else(|| Ok(HashMap::new()), |bytes| parse_shard_map_bytes(&bytes))
}
async fn put_json_map(
store: &CheckpointStore,
key: &str,
map: &HashMap<u32, i64>,
) -> Result<()> {
let json = serde_json::to_string(map).map_err(|e| {
PhotonError::Internal(format!("fluvio checkpoint encode shard map: {e}"))
})?;
let payload = Bytes::from(json);
store.cache.insert(key.to_string(), payload.clone());
produce_checkpoint(store, key, payload).await
}
async fn produce_checkpoint(store: &CheckpointStore, key: &str, payload: Bytes) -> Result<()> {
let topic = store.config.checkpoint_topic();
let producer = store
.client
.topic_producer(topic)
.await
.map_err(|e| PhotonError::Internal(format!("fluvio checkpoint producer {key}: {e}")))?;
producer
.send(key, payload.to_vec())
.await
.map_err(|e| PhotonError::Internal(format!("fluvio checkpoint commit {key}: {e}")))?
.wait()
.await
.map_err(|e| PhotonError::Internal(format!("fluvio checkpoint ack {key}: {e}")))?;
Ok(())
}
fn parse_checkpoint_bytes(bytes: &Bytes) -> Result<i64> {
let raw = std::str::from_utf8(bytes)
.map_err(|e| PhotonError::Internal(format!("fluvio checkpoint parse utf8: {e}")))?;
raw.parse::<i64>()
.map_err(|e| PhotonError::Internal(format!("fluvio checkpoint parse i64 '{raw}': {e}")))
}
fn parse_shard_map_bytes(bytes: &Bytes) -> Result<HashMap<u32, i64>> {
let raw = std::str::from_utf8(bytes)
.map_err(|e| PhotonError::Internal(format!("fluvio checkpoint parse utf8: {e}")))?;
let value: serde_json::Value = serde_json::from_str(raw)
.map_err(|e| PhotonError::Internal(format!("fluvio checkpoint parse json: {e}")))?;
let Some(obj) = value.as_object() else {
return Ok(HashMap::new());
};
let mut map = HashMap::new();
for (k, v) in obj {
if let (Ok(shard), Some(seq)) = (k.parse::<u32>(), v.as_i64()) {
map.insert(shard, seq);
}
}
Ok(map)
}
fn max_composite_from_map(map: &HashMap<u32, i64>) -> Option<i64> {
map.iter()
.map(|(shard, local)| {
composite_seq(*shard, u64::try_from((*local).max(0)).unwrap_or(0))
})
.max()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn max_composite_picks_highest_shard_seq() {
let mut map = HashMap::new();
map.insert(1, 10);
map.insert(3, 5);
assert_eq!(max_composite_from_map(&map), Some(composite_seq(3, 5)));
}
}