1#![forbid(unsafe_code)]
32
33use std::collections::BTreeMap;
34use std::sync::Mutex;
35use std::time::{Duration, SystemTime, UNIX_EPOCH};
36
37use postgres::types::ToSql;
38use postgres::{Client, NoTls};
39use zerodds_durability_store::{
40 Contract, Cursor, DurabilitySample, DurabilityStore, Page, Result, Selector, StoreError,
41 StoreStats,
42};
43use zerodds_qos::policies::history::HistoryKind;
44
45const DEFAULT_PAGE: usize = 1024;
46
47#[cfg(not(feature = "timescaledb"))]
48const SCHEMA: &str = "
49CREATE TABLE IF NOT EXISTS samples (
50 topic TEXT NOT NULL,
51 instance BYTEA NOT NULL,
52 sequence BIGINT NOT NULL,
53 created_nanos BIGINT NOT NULL,
54 payload BYTEA NOT NULL,
55 representation SMALLINT NOT NULL DEFAULT 1,
56 big_endian BOOLEAN NOT NULL DEFAULT false,
57 source_guid BYTEA NOT NULL DEFAULT '\\x00000000000000000000000000000000',
58 source_sequence BIGINT NOT NULL DEFAULT -1,
59 PRIMARY KEY (topic, instance, sequence)
60);
61CREATE INDEX IF NOT EXISTS idx_samples_topic ON samples(topic, instance, sequence);
62CREATE TABLE IF NOT EXISTS unregistered (
63 topic TEXT NOT NULL,
64 instance BYTEA NOT NULL,
65 at_nanos BIGINT NOT NULL,
66 PRIMARY KEY (topic, instance)
67);
68";
69
70#[cfg(feature = "timescaledb")]
74const SCHEMA: &str = "
75CREATE TABLE IF NOT EXISTS samples (
76 topic TEXT NOT NULL,
77 instance BYTEA NOT NULL,
78 sequence BIGINT NOT NULL,
79 created_nanos BIGINT NOT NULL,
80 payload BYTEA NOT NULL,
81 representation SMALLINT NOT NULL DEFAULT 1,
82 big_endian BOOLEAN NOT NULL DEFAULT false,
83 source_guid BYTEA NOT NULL DEFAULT '\\x00000000000000000000000000000000',
84 source_sequence BIGINT NOT NULL DEFAULT -1,
85 PRIMARY KEY (topic, instance, sequence, created_nanos)
86);
87CREATE INDEX IF NOT EXISTS idx_samples_topic ON samples(topic, instance, sequence);
88CREATE EXTENSION IF NOT EXISTS timescaledb;
89SELECT create_hypertable('samples', 'created_nanos',
90 chunk_time_interval => 86400000000000, if_not_exists => TRUE, migrate_data => TRUE);
91CREATE TABLE IF NOT EXISTS unregistered (
92 topic TEXT NOT NULL,
93 instance BYTEA NOT NULL,
94 at_nanos BIGINT NOT NULL,
95 PRIMARY KEY (topic, instance)
96);
97";
98
99pub struct PostgresStore {
101 client: Mutex<Client>,
102 contracts: Mutex<BTreeMap<String, Contract>>,
103 default_contract: Contract,
104}
105
106const SCHEMA_LOCK_KEY: i64 = 0x7A64_6473_6368_656D; fn backend(ctx: &str, e: postgres::Error) -> StoreError {
115 let detail = e
118 .as_db_error()
119 .map(|d| d.message().to_string())
120 .unwrap_or_else(|| e.to_string());
121 StoreError::Backend(format!("postgres store: {ctx}: {detail}"))
122}
123
124fn nanos_of(t: SystemTime) -> i64 {
125 t.duration_since(UNIX_EPOCH)
126 .map(|d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX))
127 .unwrap_or(0)
128}
129
130fn time_of(nanos: i64) -> SystemTime {
131 UNIX_EPOCH + Duration::from_nanos(nanos.max(0) as u64)
132}
133
134impl PostgresStore {
135 pub fn connect(conn_str: &str, default_contract: Contract) -> Result<Self> {
143 let client = Client::connect(conn_str, NoTls).map_err(|e| backend("connect", e))?;
144 Self::init(client, default_contract)
145 }
146
147 fn init(mut client: Client, default_contract: Contract) -> Result<Self> {
148 client
152 .execute("SELECT pg_advisory_lock($1)", &[&SCHEMA_LOCK_KEY])
153 .map_err(|e| backend("schema lock", e))?;
154 let schema_result = client.batch_execute(SCHEMA);
155 let unlock_result = client.execute("SELECT pg_advisory_unlock($1)", &[&SCHEMA_LOCK_KEY]);
156 schema_result.map_err(|e| backend("schema", e))?;
157 unlock_result.map_err(|e| backend("schema unlock", e))?;
158 Ok(Self {
159 client: Mutex::new(client),
160 contracts: Mutex::new(BTreeMap::new()),
161 default_contract,
162 })
163 }
164
165 fn lock_client(&self) -> Result<std::sync::MutexGuard<'_, Client>> {
166 self.client
167 .lock()
168 .map_err(|_| StoreError::Poisoned("postgres client"))
169 }
170
171 fn contract_for(&self, topic: &str) -> Result<Contract> {
172 Ok(self
173 .contracts
174 .lock()
175 .map_err(|_| StoreError::Poisoned("postgres contracts"))?
176 .get(topic)
177 .copied()
178 .unwrap_or(self.default_contract))
179 }
180
181 fn count(client: &mut Client, sql: &str, p: &[&(dyn ToSql + Sync)]) -> Result<i64> {
182 let row = client.query_one(sql, p).map_err(|e| backend("count", e))?;
183 Ok(row.get::<_, i64>(0))
184 }
185}
186
187impl DurabilityStore for PostgresStore {
188 fn set_contract(&self, topic: &str, contract: Contract) -> Result<()> {
189 self.contracts
190 .lock()
191 .map_err(|_| StoreError::Poisoned("postgres contracts"))?
192 .insert(topic.to_string(), contract);
193 Ok(())
194 }
195
196 fn store(&self, sample: DurabilitySample) -> Result<()> {
197 let contract = self.contract_for(&sample.topic)?;
198 let mut client = self.lock_client()?;
199 let inst = &sample.instance_key[..];
200 let seq = sample.sequence as i64;
201
202 let is_resend = Self::count(
206 &mut client,
207 "SELECT COUNT(*) FROM samples WHERE topic=$1 AND instance=$2 AND sequence=$3",
208 &[&sample.topic, &inst, &seq],
209 )? > 0;
210
211 if !is_resend
213 && contract.samples_bounded()
214 && matches!(contract.history_kind, HistoryKind::KeepAll)
215 {
216 let n = Self::count(
217 &mut client,
218 "SELECT COUNT(*) FROM samples WHERE topic=$1",
219 &[&sample.topic],
220 )?;
221 if n >= i64::from(contract.max_samples) {
222 return Err(StoreError::OutOfResources("max_samples"));
223 }
224 }
225 if contract.instances_bounded() {
226 let exists = Self::count(
227 &mut client,
228 "SELECT COUNT(*) FROM samples WHERE topic=$1 AND instance=$2",
229 &[&sample.topic, &inst],
230 )? > 0;
231 if !exists {
232 let insts = Self::count(
233 &mut client,
234 "SELECT COUNT(DISTINCT instance) FROM samples WHERE topic=$1",
235 &[&sample.topic],
236 )?;
237 if insts >= i64::from(contract.max_instances) {
238 return Err(StoreError::OutOfResources("max_instances"));
239 }
240 }
241 }
242 if !is_resend
243 && contract.per_instance_bounded()
244 && matches!(contract.history_kind, HistoryKind::KeepAll)
245 {
246 let n = Self::count(
247 &mut client,
248 "SELECT COUNT(*) FROM samples WHERE topic=$1 AND instance=$2",
249 &[&sample.topic, &inst],
250 )?;
251 if n >= i64::from(contract.max_samples_per_instance) {
252 return Err(StoreError::OutOfResources("max_samples_per_instance"));
253 }
254 }
255
256 let created = nanos_of(sample.created_at);
260 let rep = i16::from(sample.representation);
261 let src_guid = sample.source_guid.to_vec();
262 client
263 .execute(
264 "DELETE FROM samples WHERE topic=$1 AND instance=$2 AND sequence=$3",
265 &[&sample.topic, &inst, &seq],
266 )
267 .map_err(|e| backend("replace delete", e))?;
268 client
269 .execute(
270 "INSERT INTO samples(topic,instance,sequence,created_nanos,payload,representation,big_endian,source_guid,source_sequence) \
271 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)",
272 &[
273 &sample.topic,
274 &inst,
275 &seq,
276 &created,
277 &sample.payload,
278 &rep,
279 &sample.big_endian,
280 &src_guid,
281 &sample.source_sequence,
282 ],
283 )
284 .map_err(|e| backend("insert", e))?;
285
286 if matches!(contract.history_kind, HistoryKind::KeepLast) {
288 let depth = contract.effective_depth() as i64;
289 client
290 .execute(
291 "DELETE FROM samples WHERE topic=$1 AND instance=$2 AND sequence NOT IN \
292 (SELECT sequence FROM samples WHERE topic=$1 AND instance=$2 \
293 ORDER BY sequence DESC LIMIT $3)",
294 &[&sample.topic, &inst, &depth],
295 )
296 .map_err(|e| backend("keep_last trim", e))?;
297 }
298 Ok(())
299 }
300
301 fn query(&self, topic: &str, selector: &Selector) -> Result<Page> {
302 let mut client = self.lock_client()?;
303 let limit = selector.limit.unwrap_or(DEFAULT_PAGE);
304 let mut sql = String::from(
305 "SELECT instance,sequence,created_nanos,payload,representation,big_endian,source_guid,source_sequence \
306 FROM samples WHERE topic=$1",
307 );
308 let topic_owned = topic.to_string();
309 let mut binds: Vec<Box<dyn ToSql + Sync>> = vec![Box::new(topic_owned.clone())];
310 if let Some(k) = selector.instance_key {
311 binds.push(Box::new(k.to_vec()));
312 sql.push_str(&format!(" AND instance=${}", binds.len()));
313 }
314 if let Some(lo) = selector.seq_from {
315 binds.push(Box::new(lo as i64));
316 sql.push_str(&format!(" AND sequence>=${}", binds.len()));
317 }
318 if let Some(hi) = selector.seq_to {
319 binds.push(Box::new(hi as i64));
320 sql.push_str(&format!(" AND sequence<=${}", binds.len()));
321 }
322 if let Some(t0) = selector.time_from {
323 binds.push(Box::new(nanos_of(t0)));
324 sql.push_str(&format!(" AND created_nanos>=${}", binds.len()));
325 }
326 if let Some(t1) = selector.time_to {
327 binds.push(Box::new(nanos_of(t1)));
328 sql.push_str(&format!(" AND created_nanos<=${}", binds.len()));
329 }
330 if let Some((ck, cs)) = selector.after {
331 binds.push(Box::new(ck.to_vec()));
332 let i1 = binds.len();
333 binds.push(Box::new(ck.to_vec()));
334 let i2 = binds.len();
335 binds.push(Box::new(cs as i64));
336 let i3 = binds.len();
337 sql.push_str(&format!(
338 " AND (instance>${i1} OR (instance=${i2} AND sequence>${i3}))"
339 ));
340 }
341 binds.push(Box::new((limit + 1) as i64));
343 sql.push_str(&format!(
344 " ORDER BY instance,sequence LIMIT ${}",
345 binds.len()
346 ));
347
348 let params: Vec<&(dyn ToSql + Sync)> = binds.iter().map(|b| b.as_ref()).collect();
349 let rows = client
350 .query(&sql, params.as_slice())
351 .map_err(|e| backend("query", e))?;
352
353 let mut samples = Vec::with_capacity(rows.len());
354 for r in &rows {
355 let inst: Vec<u8> = r.get(0);
356 let mut key = [0u8; 16];
357 if inst.len() == 16 {
358 key.copy_from_slice(&inst);
359 }
360 let sg: Vec<u8> = r.get(6);
361 let mut source_guid = [0u8; 16];
362 if sg.len() == 16 {
363 source_guid.copy_from_slice(&sg);
364 }
365 samples.push(DurabilitySample {
366 topic: topic_owned.clone(),
367 instance_key: key,
368 sequence: r.get::<_, i64>(1) as u64,
369 created_at: time_of(r.get::<_, i64>(2)),
370 payload: r.get(3),
371 representation: r.get::<_, i16>(4) as u8,
372 big_endian: r.get::<_, bool>(5),
373 source_guid,
374 source_sequence: r.get::<_, i64>(7),
375 });
376 }
377 let exhausted = samples.len() <= limit;
378 samples.truncate(limit);
379 let next: Option<Cursor> = if exhausted {
380 None
381 } else {
382 samples.last().map(|s| (s.instance_key, s.sequence))
383 };
384 Ok(Page { samples, next })
385 }
386
387 fn unregister(&self, topic: &str, instance_key: &[u8; 16], now: SystemTime) -> Result<()> {
388 let mut client = self.lock_client()?;
389 let inst = &instance_key[..];
390 let at = nanos_of(now);
391 client
392 .execute(
393 "INSERT INTO unregistered(topic,instance,at_nanos) VALUES ($1,$2,$3) \
394 ON CONFLICT (topic,instance) DO UPDATE SET at_nanos=EXCLUDED.at_nanos",
395 &[&topic, &inst, &at],
396 )
397 .map_err(|e| backend("unregister", e))?;
398 Ok(())
399 }
400
401 fn cleanup(&self, now: SystemTime) -> Result<usize> {
402 let mut client = self.lock_client()?;
403 let rows = client
404 .query("SELECT topic,instance,at_nanos FROM unregistered", &[])
405 .map_err(|e| backend("cleanup scan", e))?;
406 let due: Vec<(String, Vec<u8>)> = rows
407 .iter()
408 .filter_map(|r| {
409 let topic: String = r.get(0);
410 let inst: Vec<u8> = r.get(1);
411 let at: i64 = r.get(2);
412 let delay = self.contract_for(&topic).ok()?.cleanup_delay;
413 let deadline = time_of(at).checked_add(delay)?;
414 (now >= deadline).then_some((topic, inst))
415 })
416 .collect();
417 let mut removed = 0usize;
418 for (topic, inst) in due {
419 client
420 .execute(
421 "DELETE FROM samples WHERE topic=$1 AND instance=$2",
422 &[&topic, &inst],
423 )
424 .map_err(|e| backend("cleanup delete samples", e))?;
425 client
426 .execute(
427 "DELETE FROM unregistered WHERE topic=$1 AND instance=$2",
428 &[&topic, &inst],
429 )
430 .map_err(|e| backend("cleanup delete marker", e))?;
431 removed += 1;
432 }
433 Ok(removed)
434 }
435
436 fn stats(&self, topic: &str) -> Result<StoreStats> {
437 let mut client = self.lock_client()?;
438 let samples = Self::count(
439 &mut client,
440 "SELECT COUNT(*) FROM samples WHERE topic=$1",
441 &[&topic],
442 )? as usize;
443 let instances = Self::count(
444 &mut client,
445 "SELECT COUNT(DISTINCT instance) FROM samples WHERE topic=$1",
446 &[&topic],
447 )? as usize;
448 let bytes = Self::count(
449 &mut client,
450 "SELECT COALESCE(SUM(LENGTH(payload)),0)::BIGINT FROM samples WHERE topic=$1",
451 &[&topic],
452 )? as u64;
453 Ok(StoreStats {
454 samples,
455 instances,
456 bytes,
457 })
458 }
459}