1use async_trait::async_trait;
9use bytes::Bytes;
10use krabka_connect::{CheckpointStore, ConnectError, SourceOffset};
11
12use crate::config::{ClientResourcePolicy, ReplicatorRuntimePolicy};
13
14pub(crate) const STATE_TOPIC: &str = "krabka-replicator-offsets";
16
17pub struct InternalTopicCheckpointStore {
25 producer: krabka_client_producer::Producer,
26 target_bootstrap: String,
27 topic: String,
28 key: String,
29 security: Option<krabka_client_core::security::ClientSecurity>,
30 client_resource_policy: ClientResourcePolicy,
31 runtime_policy: ReplicatorRuntimePolicy,
32 sink_transaction: bool,
36}
37
38impl InternalTopicCheckpointStore {
39 pub async fn start(
47 target_bootstrap: &str,
48 flow_name: &str,
49 security: Option<krabka_client_core::security::ClientSecurity>,
50 ) -> Result<Self, ConnectError> {
51 Self::start_with_policy(
52 target_bootstrap,
53 flow_name,
54 security,
55 ClientResourcePolicy::default(),
56 )
57 .await
58 }
59
60 pub async fn start_with_policy(
67 target_bootstrap: &str,
68 flow_name: &str,
69 security: Option<krabka_client_core::security::ClientSecurity>,
70 client_resource_policy: ClientResourcePolicy,
71 ) -> Result<Self, ConnectError> {
72 Self::start_with_runtime_policy(
73 target_bootstrap,
74 flow_name,
75 security,
76 client_resource_policy,
77 ReplicatorRuntimePolicy::default(),
78 )
79 .await
80 }
81
82 pub(crate) async fn start_with_runtime_policy(
83 target_bootstrap: &str,
84 flow_name: &str,
85 security: Option<krabka_client_core::security::ClientSecurity>,
86 client_resource_policy: ClientResourcePolicy,
87 runtime_policy: ReplicatorRuntimePolicy,
88 ) -> Result<Self, ConnectError> {
89 crate::admin_util::ensure_compacted_topic_with_runtime_policy(
90 target_bootstrap,
91 STATE_TOPIC,
92 security.clone(),
93 client_resource_policy,
94 &runtime_policy,
95 )
96 .await
97 .map_err(ConnectError::Offset)?;
98
99 let builder = krabka_client_producer::Producer::builder()
100 .bootstrap(target_bootstrap)
101 .dispatch_queue_capacity(client_resource_policy.dispatch_queue_capacity.get())
102 .frame_max(client_resource_policy.frame_max.size())
103 .enable_idempotence(false)
104 .acks(krabka_client_producer::Acks::All);
105
106 let producer = match security.clone() {
107 Some(s) => builder.security(s).build().await,
108 None => builder.build().await,
109 }
110 .map_err(|e| ConnectError::Offset(e.to_string()))?;
111
112 Ok(Self {
113 producer,
114 target_bootstrap: target_bootstrap.to_string(),
115 topic: STATE_TOPIC.into(),
116 key: flow_name.into(),
117 security,
118 client_resource_policy,
119 runtime_policy,
120 sink_transaction: false,
121 })
122 }
123
124 #[must_use]
126 pub(crate) fn with_sink_transaction(mut self, enabled: bool) -> Self {
127 self.sink_transaction = enabled;
128 self
129 }
130}
131
132#[async_trait]
133impl CheckpointStore for InternalTopicCheckpointStore {
134 #[tracing::instrument(
135 level = "debug",
136 skip_all,
137 fields(key = %self.key, positions = offset.position.len()),
138 err,
139 )]
140 async fn save(&self, offset: &SourceOffset) -> Result<(), ConnectError> {
141 if self.sink_transaction {
142 return Ok(());
143 }
144 let bytes = serde_json::to_vec(offset).map_err(|e| ConnectError::Offset(e.to_string()))?;
145
146 self.producer
147 .send(krabka_client_producer::ProducerRecord {
148 topic: self.topic.clone(),
149 partition: None,
150 key: Some(Bytes::copy_from_slice(self.key.as_bytes())),
151 value: Some(Bytes::from(bytes)),
152 headers: vec![],
153 timestamp_ms: None,
154 })
155 .await
156 .map_err(|e| ConnectError::Offset(e.to_string()))?;
157
158 self.producer
159 .flush()
160 .await
161 .map_err(|e| ConnectError::Offset(e.to_string()))?;
162
163 Ok(())
164 }
165
166 #[tracing::instrument(level = "info", skip_all, fields(key = %self.key), err)]
167 async fn load(&self) -> Result<Option<SourceOffset>, ConnectError> {
168 let latest = crate::admin_util::read_last_value_for_key_with_runtime_policy(
169 &self.target_bootstrap,
170 &self.topic,
171 self.key.as_bytes(),
172 self.security.clone(),
173 self.client_resource_policy,
174 &self.runtime_policy,
175 )
176 .await
177 .map_err(ConnectError::Offset)?;
178
179 match latest {
180 Some(bytes) => Ok(Some(
181 serde_json::from_slice(&bytes).map_err(|e| ConnectError::Offset(e.to_string()))?,
182 )),
183 None => Ok(None),
184 }
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use std::collections::BTreeMap;
191
192 use krabka_connect::{CheckpointStore, OffsetValue, SourceOffset};
193
194 use super::*;
195
196 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
197 async fn persists_and_reloads_position_from_target() {
198 let dir = tempfile::TempDir::new().unwrap();
199 let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
200 dir.path().to_path_buf(),
201 ))
202 .await
203 .unwrap();
204 let target = broker.listen_addr().to_string();
205
206 let store = InternalTopicCheckpointStore::start(&target, "flow1", None)
207 .await
208 .unwrap();
209
210 let mut pos = BTreeMap::new();
211 pos.insert("orders-0".to_string(), OffsetValue::Long(42));
212 let off = SourceOffset::new(BTreeMap::new().into(), pos.into());
213
214 store.save(&off).await.unwrap();
215
216 let store2 = InternalTopicCheckpointStore::start(&target, "flow1", None)
217 .await
218 .unwrap();
219 let loaded = store2.load().await.unwrap().unwrap();
220 assert2::assert!(loaded == off);
221 }
222}