1use std::sync::Arc;
2
3use serde::{Serialize, de::DeserializeOwned};
4use sqlx::{PgPool, Row};
5
6use eventuary_core::io::reader::{CheckpointKey, CheckpointScope, CheckpointStore};
7use eventuary_core::io::{Cursor, CursorId};
8use eventuary_core::{Error, Result};
9
10use crate::relation::PgRelationName;
11use crate::schema::{Migration, RelationReplacement};
12
13const CHECKPOINT_STORE_0001_INIT_SQL: &str = r#"
14CREATE TABLE IF NOT EXISTS {offsets} (
15 consumer_group_id TEXT NOT NULL,
16 stream_id TEXT NOT NULL DEFAULT 'default',
17 cursor_id TEXT NOT NULL,
18 cursor JSONB NOT NULL,
19 cursor_order BYTEA NOT NULL DEFAULT ''::bytea,
20 PRIMARY KEY (consumer_group_id, stream_id, cursor_id)
21);
22"#;
23
24const CHECKPOINT_STORE_MIGRATIONS: &[Migration] = &[Migration {
25 name: "0001_init",
26 sql: CHECKPOINT_STORE_0001_INIT_SQL,
27}];
28
29#[derive(Debug, Clone)]
30pub struct PgCheckpointStoreConfig {
31 pub offsets_relation: PgRelationName,
32}
33
34impl Default for PgCheckpointStoreConfig {
35 fn default() -> Self {
36 Self {
37 offsets_relation: PgRelationName::new("consumer_offsets")
38 .expect("default offsets relation"),
39 }
40 }
41}
42
43pub struct PgCheckpointStore<C> {
44 pool: PgPool,
45 relation: Arc<String>,
46 _cursor: std::marker::PhantomData<fn() -> C>,
47}
48
49impl<C> Clone for PgCheckpointStore<C> {
50 fn clone(&self) -> Self {
51 Self {
52 pool: self.pool.clone(),
53 relation: Arc::clone(&self.relation),
54 _cursor: std::marker::PhantomData,
55 }
56 }
57}
58
59impl<C> PgCheckpointStore<C> {
60 pub fn new(pool: PgPool, config: PgCheckpointStoreConfig) -> Self {
61 Self {
62 pool,
63 relation: Arc::new(config.offsets_relation.render()),
64 _cursor: std::marker::PhantomData,
65 }
66 }
67
68 pub async fn connect(pool: PgPool, config: PgCheckpointStoreConfig) -> Result<Self> {
69 Self::prepare_schema(&pool, &config).await?;
70 Ok(Self::new(pool, config))
71 }
72
73 pub async fn prepare_schema(pool: &PgPool, config: &PgCheckpointStoreConfig) -> Result<()> {
74 crate::schema::apply_schema(
75 pool,
76 CHECKPOINT_STORE_MIGRATIONS,
77 &[RelationReplacement {
78 token: "{offsets}",
79 relation: &config.offsets_relation,
80 }],
81 )
82 .await
83 }
84
85 pub fn schema_sql(config: &PgCheckpointStoreConfig) -> String {
86 crate::schema::render_schema_sql(
87 CHECKPOINT_STORE_MIGRATIONS,
88 &[RelationReplacement {
89 token: "{offsets}",
90 relation: &config.offsets_relation,
91 }],
92 )
93 }
94}
95
96fn encode_cursor_id(cursor_id: &CursorId) -> &str {
97 cursor_id.as_str()
98}
99
100fn decode_cursor_id(value: String) -> CursorId {
101 CursorId::new(value).unwrap_or_else(|_| CursorId::global())
102}
103
104fn encode_cursor<C: Serialize>(cursor: &C) -> Result<serde_json::Value> {
105 serde_json::to_value(cursor)
106 .map_err(|e| Error::Serialization(format!("checkpoint encode: {e}")))
107}
108
109fn decode_cursor<C: DeserializeOwned>(value: serde_json::Value) -> Result<C> {
110 serde_json::from_value(value)
111 .map_err(|e| Error::Serialization(format!("checkpoint decode: {e}")))
112}
113
114impl<C> CheckpointStore<C> for PgCheckpointStore<C>
115where
116 C: Cursor + Serialize + DeserializeOwned + Clone + Send + Sync + 'static,
117{
118 async fn load(&self, key: &CheckpointKey) -> Result<Option<C>> {
119 let cursor_id = encode_cursor_id(&key.cursor_id);
120 let sql = format!(
121 "SELECT cursor FROM {relation} \
122 WHERE consumer_group_id = $1 AND stream_id = $2 AND cursor_id = $3",
123 relation = self.relation
124 );
125 let row = sqlx::query(&sql)
126 .bind(key.scope.consumer_group_id.as_str())
127 .bind(key.scope.stream_id.as_str())
128 .bind(cursor_id)
129 .fetch_optional(&self.pool)
130 .await
131 .map_err(|e| Error::Store(e.to_string()))?;
132 match row {
133 Some(r) => Ok(Some(decode_cursor::<C>(
134 r.get::<serde_json::Value, _>("cursor"),
135 )?)),
136 None => Ok(None),
137 }
138 }
139
140 async fn load_scope(&self, scope: &CheckpointScope) -> Result<Vec<(CursorId, C)>> {
141 let sql = format!(
142 "SELECT cursor_id, cursor FROM {relation} \
143 WHERE consumer_group_id = $1 AND stream_id = $2",
144 relation = self.relation
145 );
146 let rows = sqlx::query(&sql)
147 .bind(scope.consumer_group_id.as_str())
148 .bind(scope.stream_id.as_str())
149 .fetch_all(&self.pool)
150 .await
151 .map_err(|e| Error::Store(e.to_string()))?;
152 let mut out = Vec::with_capacity(rows.len());
153 for row in rows {
154 let cursor_id: String = row.get("cursor_id");
155 let cursor: serde_json::Value = row.get("cursor");
156 out.push((decode_cursor_id(cursor_id), decode_cursor::<C>(cursor)?));
157 }
158 Ok(out)
159 }
160
161 async fn commit(&self, key: &CheckpointKey, cursor: C) -> Result<()> {
162 let cursor_id = encode_cursor_id(&key.cursor_id);
163 let cursor_json = encode_cursor(&cursor)?;
164 let cursor_order = cursor.order_key();
165 let sql = format!(
166 "INSERT INTO {relation} \
167 (consumer_group_id, stream_id, cursor_id, cursor, cursor_order) \
168 VALUES ($1, $2, $3, $4, $5) \
169 ON CONFLICT (consumer_group_id, stream_id, cursor_id) DO UPDATE \
170 SET cursor = EXCLUDED.cursor, \
171 cursor_order = EXCLUDED.cursor_order \
172 WHERE {relation}.cursor_order < EXCLUDED.cursor_order",
173 relation = self.relation
174 );
175 sqlx::query(&sql)
176 .bind(key.scope.consumer_group_id.as_str())
177 .bind(key.scope.stream_id.as_str())
178 .bind(cursor_id)
179 .bind(cursor_json)
180 .bind(cursor_order.as_bytes())
181 .execute(&self.pool)
182 .await
183 .map_err(|e| Error::Store(e.to_string()))?;
184 Ok(())
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use eventuary_core::Partition;
192 use std::num::NonZeroU32;
193
194 #[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
195 struct WrappedCursor {
196 sequence: i64,
197 partition: Partition,
198 }
199
200 #[test]
201 fn encode_cursor_preserves_nested_json() {
202 let partition = Partition::new(2, NonZeroU32::new(4).unwrap()).unwrap();
203 let cursor = WrappedCursor {
204 sequence: 42,
205 partition,
206 };
207
208 let value = encode_cursor(&cursor).unwrap();
209 let decoded: WrappedCursor = decode_cursor(value).unwrap();
210
211 assert_eq!(decoded, cursor);
212 }
213
214 #[test]
215 fn cursor_id_global_encodes_as_plain_string() {
216 assert_eq!(encode_cursor_id(&CursorId::global()), "global");
217 assert_eq!(decode_cursor_id("global".to_owned()), CursorId::global());
218 }
219
220 #[test]
221 fn cursor_id_named_roundtrips_unquoted() {
222 let id = CursorId::partition(
223 eventuary_core::partition::Partition::new(17, std::num::NonZeroU32::new(100).unwrap())
224 .unwrap(),
225 );
226 let encoded = encode_cursor_id(&id).to_owned();
227 assert_eq!(encoded, "partition:100:17");
228 assert_eq!(decode_cursor_id(encoded), id);
229 }
230
231 #[test]
232 fn schema_sql_contains_expected_table() {
233 let sql = PgCheckpointStore::<crate::reader::PgCursor>::schema_sql(
234 &PgCheckpointStoreConfig::default(),
235 );
236 assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"consumer_offsets\""));
237 }
238}