liminal/durability/
cursor.rs1use crate::durability::settle::cas_settled;
2
3use super::{CheckpointPolicy, DurabilityConfig, DurabilityError, DurableStore};
4
5#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct ConsumerCursor {
8 pub consumer_id: String,
10 pub partition_key: String,
12 pub current_offset: u64,
14}
15
16impl ConsumerCursor {
17 #[must_use]
19 pub fn new(consumer_id: impl Into<String>, partition_key: impl Into<String>) -> Self {
20 Self::from_persisted(consumer_id, partition_key, 0)
21 }
22
23 #[must_use]
25 pub fn from_persisted(
26 consumer_id: impl Into<String>,
27 partition_key: impl Into<String>,
28 current_offset: u64,
29 ) -> Self {
30 Self {
31 consumer_id: consumer_id.into(),
32 partition_key: partition_key.into(),
33 current_offset,
34 }
35 }
36
37 pub async fn resume(
43 consumer_id: impl Into<String>,
44 partition_key: impl Into<String>,
45 store: &dyn DurableStore,
46 ) -> Result<Self, DurabilityError> {
47 let consumer_id = consumer_id.into();
48 let partition_key = partition_key.into();
49 let cursor_key = cursor_key_for(&consumer_id, &partition_key);
50 let current_offset = store.read_value(&cursor_key).await?.unwrap_or(0);
51 Ok(Self::from_persisted(
52 consumer_id,
53 partition_key,
54 current_offset,
55 ))
56 }
57
58 #[must_use]
60 pub fn consumer_id(&self) -> &str {
61 &self.consumer_id
62 }
63
64 #[must_use]
66 pub fn partition_key(&self) -> &str {
67 &self.partition_key
68 }
69
70 #[must_use]
72 pub const fn current_offset(&self) -> u64 {
73 self.current_offset
74 }
75
76 #[must_use]
78 pub fn cursor_key(&self) -> String {
79 cursor_key_for(&self.consumer_id, &self.partition_key)
80 }
81
82 pub async fn checkpoint(
89 &mut self,
90 store: &dyn DurableStore,
91 new_offset: u64,
92 ) -> Result<(), DurabilityError> {
93 if new_offset < self.current_offset {
94 return Err(DurabilityError::CursorRegression {
95 stored: self.current_offset,
96 attempted: new_offset,
97 });
98 }
99
100 cas_settled(store, &self.cursor_key(), self.current_offset, new_offset).await?;
101 self.current_offset = new_offset;
102 Ok(())
103 }
104}
105
106#[derive(Clone, Debug, PartialEq, Eq)]
108pub struct CheckpointDriver {
109 policy: CheckpointPolicy,
110 messages_since_last_checkpoint: usize,
111 pending_offset: Option<u64>,
112}
113
114impl CheckpointDriver {
115 #[must_use]
117 pub fn new(policy: impl Into<CheckpointPolicy>) -> Self {
118 Self::from_policy(policy.into())
119 }
120
121 #[must_use]
123 pub const fn from_policy(policy: CheckpointPolicy) -> Self {
124 Self {
125 policy,
126 messages_since_last_checkpoint: 0,
127 pending_offset: None,
128 }
129 }
130
131 #[must_use]
133 pub const fn from_config(config: DurabilityConfig) -> Self {
134 Self::from_policy(config.checkpoint_policy())
135 }
136
137 #[must_use]
139 pub const fn policy(&self) -> CheckpointPolicy {
140 self.policy
141 }
142
143 #[must_use]
145 pub const fn messages_since_last_checkpoint(&self) -> usize {
146 self.messages_since_last_checkpoint
147 }
148
149 #[must_use]
151 pub const fn pending_offset(&self) -> Option<u64> {
152 self.pending_offset
153 }
154
155 pub async fn record_processed(
165 &mut self,
166 cursor: &mut ConsumerCursor,
167 store: &dyn DurableStore,
168 next_offset: u64,
169 ) -> Result<(), DurabilityError> {
170 self.validate_policy()?;
171 self.messages_since_last_checkpoint = self
172 .messages_since_last_checkpoint
173 .checked_add(1)
174 .ok_or_else(|| {
175 DurabilityError::ConfigError("messages since last checkpoint overflow".to_owned())
176 })?;
177 self.pending_offset = Some(next_offset);
178
179 match self.policy {
180 CheckpointPolicy::PerMessage => self.checkpoint_pending(cursor, store).await,
181 CheckpointPolicy::PerBatch(batch_size)
182 if self.messages_since_last_checkpoint >= batch_size =>
183 {
184 self.checkpoint_pending(cursor, store).await
185 }
186 CheckpointPolicy::PerBatch(_) | CheckpointPolicy::ExplicitFlush => Ok(()),
187 }
188 }
189
190 pub async fn flush(
197 &mut self,
198 cursor: &mut ConsumerCursor,
199 store: &dyn DurableStore,
200 ) -> Result<(), DurabilityError> {
201 self.validate_policy()?;
202 self.checkpoint_pending(cursor, store).await
203 }
204
205 async fn checkpoint_pending(
206 &mut self,
207 cursor: &mut ConsumerCursor,
208 store: &dyn DurableStore,
209 ) -> Result<(), DurabilityError> {
210 let Some(next_offset) = self.pending_offset else {
211 return Ok(());
212 };
213 cursor.checkpoint(store, next_offset).await?;
214 self.messages_since_last_checkpoint = 0;
215 self.pending_offset = None;
216 Ok(())
217 }
218
219 fn validate_policy(&self) -> Result<(), DurabilityError> {
220 if self.policy == CheckpointPolicy::PerBatch(0) {
221 return Err(DurabilityError::ConfigError(
222 "checkpoint batch size must be at least 1".to_owned(),
223 ));
224 }
225 Ok(())
226 }
227}
228
229impl From<DurabilityConfig> for CheckpointPolicy {
230 fn from(config: DurabilityConfig) -> Self {
231 config.checkpoint_policy()
232 }
233}
234
235#[must_use]
237pub fn cursor_key_for(consumer_id: &str, partition_key: &str) -> String {
238 format!("{consumer_id}:{partition_key}")
239}
240
241#[cfg(test)]
242mod tests;