1use std::{
8 sync::{
9 Arc,
10 atomic::{AtomicBool, Ordering},
11 },
12 time::Duration,
13};
14
15use ::postgres::{Client, Config as PgClientConfig, NoTls};
16use myko::{
17 event::{MEvent, MEventType},
18 server::{HandlerRegistry, PersistError, PersistHealth, Persister},
19 store::StoreRegistry,
20};
21use postgres::fallible_iterator::FallibleIterator;
22use tracing::{debug, error, info, trace, warn};
23use uuid::Uuid;
24
25const PG_CONNECT_TIMEOUT_SECS: u64 = 10;
26const PG_KEEPALIVE_IDLE_SECS: u64 = 30;
27const PG_KEEPALIVE_INTERVAL_SECS: u64 = 10;
28const PG_KEEPALIVE_RETRIES: u32 = 3;
29const PG_TCP_USER_TIMEOUT_SECS: u64 = 30;
36const PG_PRODUCER_MAX_BATCH: usize = 256;
37
38#[derive(Debug, Clone)]
40pub struct PostgresConfig {
41 pub url: String,
43 pub table: String,
45 pub channel: String,
47}
48
49#[derive(Debug, Clone)]
51pub struct PersistedEvent {
52 pub id: i64,
53 pub created_at: String,
54 pub event: MEvent,
55}
56
57impl PostgresConfig {
58 pub fn from_env() -> Option<Self> {
64 let url = std::env::var("MYKO_POSTGRES_URL").ok()?;
65 let table = std::env::var("MYKO_POSTGRES_TABLE").unwrap_or_else(|_| "myko_events".into());
66 let channel =
67 std::env::var("MYKO_POSTGRES_CHANNEL").unwrap_or_else(|_| "myko_events_notify".into());
68 Some(Self {
69 url,
70 table,
71 channel,
72 })
73 }
74}
75
76pub struct PostgresHistoryStore {
78 config: PostgresConfig,
79}
80
81impl PostgresHistoryStore {
82 pub fn new(config: PostgresConfig) -> Result<Self, String> {
84 validate_ident(&config.table)?;
85 validate_ident(&config.channel)?;
86 Ok(Self { config })
87 }
88
89 pub fn load_after_id(&self, after_id: i64, limit: i64) -> Result<Vec<PersistedEvent>, String> {
91 let mut client = connect_pg_client(&self.config, "history(load_after_id)")?;
92 let sql = format!(
93 "SELECT id, created_at::text, event::text FROM {} WHERE id > $1 ORDER BY id ASC LIMIT $2",
94 qi(&self.config.table)
95 );
96 let rows = client
97 .query(&sql, &[&after_id, &limit])
98 .map_err(|e| format!("history query failed: {e}"))?;
99 rows.into_iter()
100 .map(row_to_persisted_event)
101 .collect::<Result<Vec<_>, _>>()
102 }
103
104 pub fn load_until(
106 &self,
107 after_id: i64,
108 until: &str,
109 limit: i64,
110 ) -> Result<Vec<PersistedEvent>, String> {
111 let mut client = connect_pg_client(&self.config, "history(load_until)")?;
112 if !until
114 .chars()
115 .all(|c| c.is_ascii_alphanumeric() || "-.:+TZ ".contains(c))
116 {
117 return Err(format!("Invalid timestamp format: {}", until));
118 }
119 let sql = format!(
120 "SELECT id, created_at::text, event::text FROM {} WHERE id > {} AND created_at <= '{}'::timestamptz ORDER BY id ASC LIMIT {}",
121 qi(&self.config.table),
122 after_id,
123 until,
124 limit
125 );
126 let rows = client
127 .query(&sql, &[])
128 .map_err(|e| format!("history query failed: {e}"))?;
129 rows.into_iter()
130 .map(row_to_persisted_event)
131 .collect::<Result<Vec<_>, _>>()
132 }
133
134 pub fn load_between(
136 &self,
137 from_iso: &str,
138 to_iso: &str,
139 limit: i64,
140 ) -> Result<Vec<PersistedEvent>, String> {
141 let mut client = connect_pg_client(&self.config, "history(load_between)")?;
142 let sql = format!(
143 "SELECT id, created_at::text, event::text FROM {} WHERE created_at >= $1::timestamptz AND created_at <= $2::timestamptz ORDER BY id ASC LIMIT $3",
144 qi(&self.config.table)
145 );
146 let rows = client
147 .query(&sql, &[&from_iso, &to_iso, &limit])
148 .map_err(|e| format!("history query failed: {e}"))?;
149 rows.into_iter()
150 .map(row_to_persisted_event)
151 .collect::<Result<Vec<_>, _>>()
152 }
153}
154
155pub struct PostgresHistoryReplayProvider {
157 config: PostgresConfig,
158}
159
160impl PostgresHistoryReplayProvider {
161 pub fn new(config: PostgresConfig) -> Self {
162 Self { config }
163 }
164}
165
166impl myko::server::HistoryReplayProvider for PostgresHistoryReplayProvider {
167 fn replay_to_store(
168 &self,
169 until: &str,
170 handler_registry: &HandlerRegistry,
171 ) -> Result<Arc<StoreRegistry>, String> {
172 eprintln!(
173 "[HistoryReplay] loading snapshot as of {} from {}",
174 until, self.config.url
175 );
176
177 if !until
179 .chars()
180 .all(|c| c.is_ascii_alphanumeric() || "-.:+TZ ".contains(c))
181 {
182 return Err(format!("Invalid timestamp format: {}", until));
183 }
184
185 let mut client = connect_pg_client(&self.config, "history_replay")?;
186 let table = qi(&self.config.table);
187 let registry = StoreRegistry::new();
188
189 let sql = format!(
193 "
194 WITH latest AS (
195 SELECT DISTINCT ON (item_type, item_id)
196 id, change_type
197 FROM {table}
198 WHERE created_at <= '{until}'::timestamptz
199 ORDER BY item_type, item_id, id DESC
200 )
201 SELECT e.event::text
202 FROM latest
203 JOIN {table} e ON e.id = latest.id
204 WHERE latest.change_type = 'SET'
205 ORDER BY e.id ASC
206 "
207 );
208
209 let rows = client
210 .query(&sql, &[])
211 .map_err(|e| format!("history snapshot query failed: {e}"))?;
212
213 let mut count = 0usize;
214 for row in &rows {
215 let event_json: String = row.get(0);
216 match MEvent::from_str_trim(&event_json) {
217 Ok(event) => {
218 if let Some(parse) = handler_registry.get_item_parser(&event.item_type)
219 && let Ok(item) = parse(event.item.clone())
220 {
221 let store = registry.get_or_create(&event.item_type);
222 store.insert(item.id(), item);
223 count += 1;
224 }
225 }
226 Err(err) => {
227 eprintln!("[HistoryReplay] invalid event row: {}", err);
228 }
229 }
230 }
231
232 eprintln!(
233 "[HistoryReplay] loaded {} entities from {} rows as of {}",
234 count,
235 rows.len(),
236 until
237 );
238
239 Ok(Arc::new(registry))
240 }
241}
242
243type ProducerRequest = MEvent;
244
245#[derive(Clone)]
247pub struct PostgresProducerHandle {
248 sender: flume::Sender<ProducerRequest>,
249 host_id: Uuid,
250 config: PostgresConfig,
251 health: Arc<PersistHealth>,
252}
253
254impl PostgresProducerHandle {
255 pub fn produce(&self, mut event: MEvent) -> Result<(), PersistError> {
258 if event.source_id.is_none() {
259 event.source_id = Some(self.host_id.to_string());
260 }
261 let entity_type = event.item_type.clone();
262
263 match self.sender.send(event) {
264 Ok(()) => {
265 self.health.record_enqueue();
266 Ok(())
267 }
268 Err(_) => {
269 let msg = "Postgres producer thread not running".to_string();
270 self.health.record_dropped(msg.clone());
271 Err(PersistError {
272 entity_type,
273 message: msg,
274 })
275 }
276 }
277 }
278}
279
280impl Persister for PostgresProducerHandle {
281 fn persist(&self, event: MEvent) -> Result<(), PersistError> {
282 self.produce(event)
283 }
284
285 fn health(&self) -> Arc<PersistHealth> {
286 self.health.clone()
287 }
288
289 fn startup_healthcheck(&self) -> Result<(), String> {
290 validate_ident(&self.config.table)?;
291 validate_ident(&self.config.channel)?;
292 Ok(())
293 }
294}
295
296pub struct CellPostgresProducer {
298 handle: PostgresProducerHandle,
299}
300
301impl CellPostgresProducer {
302 pub fn new(config: &PostgresConfig, host_id: Uuid) -> Result<Self, String> {
304 validate_ident(&config.table)?;
305 validate_ident(&config.channel)?;
306
307 let health = Arc::new(PersistHealth::default());
308 let (tx, rx) = flume::unbounded::<ProducerRequest>();
309 let cfg = config.clone();
310 let thread_health = health.clone();
311 std::thread::spawn(move || run_producer_loop(cfg, rx, thread_health));
312
313 Ok(Self {
314 handle: PostgresProducerHandle {
315 sender: tx,
316 host_id,
317 config: config.clone(),
318 health,
319 },
320 })
321 }
322
323 pub fn handle(&self) -> PostgresProducerHandle {
325 self.handle.clone()
326 }
327}
328
329fn run_producer_loop(
330 config: PostgresConfig,
331 rx: flume::Receiver<ProducerRequest>,
332 health: Arc<PersistHealth>,
333) {
334 let mut client: Option<Client> = None;
335 let mut retry_batch: Vec<MEvent> = Vec::new();
336
337 loop {
338 let mut batch: Vec<MEvent> = Vec::new();
340 if !retry_batch.is_empty() {
341 std::mem::swap(&mut batch, &mut retry_batch);
342 } else {
343 match rx.recv() {
344 Ok(ev) => batch.push(ev),
345 Err(_) => break, }
347 }
348 while batch.len() < PG_PRODUCER_MAX_BATCH {
350 match rx.try_recv() {
351 Ok(ev) => batch.push(ev),
352 Err(_) => break,
353 }
354 }
355
356 if client.is_none() {
357 client = connect_producer_client(&config);
358 }
359
360 let batch_len = batch.len();
361 let pending = rx.len();
362 if pending > 1000 {
363 trace!(
364 "Postgres producer backlog: {} pending events in channel",
365 pending
366 );
367 }
368 if tracing::enabled!(tracing::Level::DEBUG) {
369 let mut counts: std::collections::BTreeMap<(&str, &str), usize> =
370 std::collections::BTreeMap::new();
371 for ev in &batch {
372 let kind = match ev.change_type {
373 MEventType::SET => "SET",
374 MEventType::DEL => "DEL",
375 };
376 *counts.entry((ev.item_type.as_str(), kind)).or_insert(0) += 1;
377 }
378 let summary: Vec<String> = counts
379 .iter()
380 .map(|((t, k), n)| format!("{}:{}={}", t, k, n))
381 .collect();
382 debug!(
383 "[pg-producer] batch_len={} pending_after={} kinds=[{}]",
384 batch_len,
385 pending,
386 summary.join(", ")
387 );
388 }
389 if let Some(c) = client.as_mut() {
390 match insert_event_batch(c, &config, &batch) {
391 Ok(()) => {
392 health.record_success_batch(batch_len as u64);
393 }
394 Err(err) => {
395 let msg =
396 format_pg_error("insert_event_batch(producer)", Some(&config.url), &err);
397 error!("{}", msg);
398 health.record_error_no_dequeue(msg);
399 client = None;
400 retry_batch = batch;
401 std::thread::sleep(Duration::from_secs(1));
403 }
404 }
405 } else {
406 let msg = format!(
407 "Postgres producer connection failed (url: {})",
408 redact_pg_url(&config.url),
409 );
410 error!("{}", msg);
411 health.record_error_no_dequeue(msg);
412 retry_batch = batch;
413 std::thread::sleep(Duration::from_secs(1));
414 }
415 }
416}
417
418fn connect_producer_client(config: &PostgresConfig) -> Option<Client> {
419 match connect_pg_client(config, "producer") {
420 Ok(mut c) => match ensure_schema(&mut c, config) {
421 Ok(()) => Some(c),
422 Err(err) => {
423 error!(
424 "{}",
425 format_pg_error("ensure_schema(producer)", Some(&config.url), &err)
426 );
427 None
428 }
429 },
430 Err(err) => {
431 error!("{err}");
432 None
433 }
434 }
435}
436
437fn insert_event_batch(
438 client: &mut Client,
439 config: &PostgresConfig,
440 events: &[MEvent],
441) -> Result<(), ::postgres::Error> {
442 if events.is_empty() {
443 return Ok(());
444 }
445
446 let table = qi(&config.table);
447
448 if events.len() == 1 {
450 let event = &events[0];
451 let sql = format!(
452 "INSERT INTO {table} (item_type, item_id, change_type, created_at, tx, source_id, event) VALUES ($1, $2, $3, ($4::text)::timestamptz, $5, ($6::text), ($7::text)::jsonb)"
453 );
454 let item_id = event
455 .item
456 .get("id")
457 .and_then(|v| v.as_str())
458 .unwrap_or("unknown")
459 .to_string();
460 let event_json = serde_json::to_string(event).unwrap_or_else(|_| "{}".to_string());
461 let change_type = match event.change_type {
462 MEventType::SET => "SET",
463 MEventType::DEL => "DEL",
464 };
465 client.execute(
466 &sql,
467 &[
468 &event.item_type,
469 &item_id,
470 &change_type,
471 &event.created_at,
472 &event.tx,
473 &event.source_id,
474 &event_json,
475 ],
476 )?;
477 return Ok(());
478 }
479
480 let mut sql = format!(
482 "INSERT INTO {table} (item_type, item_id, change_type, created_at, tx, source_id, event) VALUES "
483 );
484 let mut params: Vec<Box<dyn postgres::types::ToSql + Sync>> =
485 Vec::with_capacity(events.len() * 7);
486 for (i, event) in events.iter().enumerate() {
487 if i > 0 {
488 sql.push_str(", ");
489 }
490 let base = i * 7;
491 sql.push_str(&format!(
492 "(${}, ${}, ${}, (${}::text)::timestamptz, ${}, (${}::text), (${}::text)::jsonb)",
493 base + 1,
494 base + 2,
495 base + 3,
496 base + 4,
497 base + 5,
498 base + 6,
499 base + 7
500 ));
501 let item_id = event
502 .item
503 .get("id")
504 .and_then(|v| v.as_str())
505 .unwrap_or("unknown")
506 .to_string();
507 let event_json = serde_json::to_string(event).unwrap_or_else(|_| "{}".to_string());
508 let change_type = match event.change_type {
509 MEventType::SET => "SET",
510 MEventType::DEL => "DEL",
511 };
512 params.push(Box::new(event.item_type.clone()));
513 params.push(Box::new(item_id));
514 params.push(Box::new(change_type.to_string()));
515 params.push(Box::new(event.created_at.clone()));
516 params.push(Box::new(event.tx.clone()));
517 params.push(Box::new(event.source_id.clone()));
518 params.push(Box::new(event_json));
519 }
520
521 let param_refs: Vec<&(dyn postgres::types::ToSql + Sync)> =
522 params.iter().map(|p| p.as_ref()).collect();
523
524 let mut txn = client.transaction()?;
525 txn.execute(&sql, ¶m_refs)?;
526 txn.commit()?;
527
528 Ok(())
529}
530
531#[derive(Debug)]
533pub struct CatchUpStatus {
534 caught_up: AtomicBool,
535 failed: AtomicBool,
536 failure_reason: std::sync::RwLock<Option<String>>,
537}
538
539impl CatchUpStatus {
540 fn new() -> Self {
541 Self {
542 caught_up: AtomicBool::new(false),
543 failed: AtomicBool::new(false),
544 failure_reason: std::sync::RwLock::new(None),
545 }
546 }
547
548 pub fn is_caught_up(&self) -> bool {
550 self.caught_up.load(Ordering::SeqCst)
551 }
552
553 pub fn is_failed(&self) -> bool {
555 self.failed.load(Ordering::SeqCst)
556 }
557
558 fn fail(&self, reason: impl Into<String>) {
559 let reason = reason.into();
560 *self.failure_reason.write().unwrap() = Some(reason.clone());
561 self.failed.store(true, Ordering::SeqCst);
562 self.caught_up.store(false, Ordering::SeqCst);
563 }
564
565 pub fn wait_until_caught_up(&self, timeout: Duration) -> Result<(), String> {
567 let start = std::time::Instant::now();
568 while !self.is_caught_up() {
569 if self.is_failed() {
570 return Err(self
571 .failure_reason
572 .read()
573 .unwrap()
574 .clone()
575 .unwrap_or_else(|| "Postgres catch-up failed".to_string()));
576 }
577 if start.elapsed() >= timeout {
578 return Err(format!(
579 "Postgres catch-up timed out after {}s",
580 timeout.as_secs()
581 ));
582 }
583 std::thread::sleep(Duration::from_millis(50));
584 }
585 Ok(())
586 }
587}
588
589pub struct CellPostgresConsumer {
591 catch_up_status: Arc<CatchUpStatus>,
592 _handle: std::thread::JoinHandle<()>,
593}
594
595impl CellPostgresConsumer {
596 pub fn start(
598 config: &PostgresConfig,
599 host_id: Uuid,
600 handler_registry: Arc<HandlerRegistry>,
601 registry: Arc<StoreRegistry>,
602 ) -> Result<Self, String> {
603 validate_ident(&config.table)?;
604 validate_ident(&config.channel)?;
605
606 let catch_up_status = Arc::new(CatchUpStatus::new());
607 let status = catch_up_status.clone();
608 let cfg = config.clone();
609 let host_id_string = host_id.to_string();
610
611 let handle = std::thread::spawn(move || {
612 if let Err(err) = run_consumer_loop(
613 &cfg,
614 &host_id_string,
615 handler_registry,
616 registry,
617 status.clone(),
618 ) {
619 let reason = format!("Postgres consumer failed: {err}");
620 error!("{reason}");
621 status.fail(reason);
622 }
623 });
624
625 Ok(Self {
626 catch_up_status,
627 _handle: handle,
628 })
629 }
630
631 pub fn is_caught_up(&self) -> bool {
633 self.catch_up_status.is_caught_up()
634 }
635
636 pub fn wait_until_caught_up(&self, timeout: Duration) -> Result<(), String> {
638 self.catch_up_status.wait_until_caught_up(timeout)
639 }
640}
641
642fn run_consumer_loop(
643 config: &PostgresConfig,
644 host_id: &str,
645 handler_registry: Arc<HandlerRegistry>,
646 registry: Arc<StoreRegistry>,
647 status: Arc<CatchUpStatus>,
648) -> Result<(), String> {
649 let mut reader = connect_consumer_client("reader", config)?;
650 let mut listener = connect_listener_client(config)?;
651
652 info!(
653 "CellPostgresConsumer started (table={}, channel={})",
654 config.table, config.channel
655 );
656
657 let table = qi(&config.table);
658 let high_water_sql = format!("SELECT COALESCE(MAX(id), 0) FROM {table}");
659 let high_water_row = reader
660 .query_one(&high_water_sql, &[])
661 .map_err(|e| format_pg_error("query(high_water)", Some(&config.url), &e))?;
662 let high_water: i64 = high_water_row.get(0);
663 let snapshot_sql = format!(
672 "
673 WITH latest AS (
674 SELECT DISTINCT ON (item_type, item_id)
675 id, change_type
676 FROM {table}
677 WHERE id <= $1
678 ORDER BY item_type, item_id, id DESC
679 )
680 SELECT e.id, e.event::text
681 FROM latest
682 JOIN {table} e ON e.id = latest.id
683 WHERE latest.change_type = 'SET'
684 ORDER BY e.id ASC
685 "
686 );
687 let snapshot_count: usize = {
696 let mut snapshot_rows = reader
697 .query_raw(
698 &snapshot_sql,
699 [&high_water as &(dyn postgres::types::ToSql + Sync)],
700 )
701 .map_err(|e| format_pg_error("query(snapshot latest events)", Some(&config.url), &e))?;
702 let mut count: usize = 0;
703 while let Some(row) = snapshot_rows
704 .next()
705 .map_err(|e| format_pg_error("stream(snapshot latest events)", Some(&config.url), &e))?
706 {
707 let id: i64 = row.get(0);
708 let event_json: String = row.get(1);
709 match MEvent::from_str_trim(&event_json) {
710 Ok(event) => {
711 apply_remote_event(event, host_id, &handler_registry, ®istry);
712 }
713 Err(err) => {
714 error!("Invalid postgres snapshot row id={id}: {err}");
715 }
716 }
717 count += 1;
718 }
719 count
720 };
721 info!(
722 "Postgres snapshot loaded latest state rows={} high_water={}",
723 snapshot_count, high_water
724 );
725
726 let fetch_sql =
727 format!("SELECT id, event::text FROM {table} WHERE id > $1 ORDER BY id ASC LIMIT $2");
728 let mut last_seen_id: i64 = high_water;
729 let mut initial_done = false;
730
731 loop {
732 let rows = match reader.query(&fetch_sql, &[&last_seen_id, &1000_i64]) {
733 Ok(rows) => rows,
734 Err(err) => {
735 warn!(
736 "{}",
737 format_pg_error("query(fetch events)", Some(&config.url), &err)
738 );
739 std::thread::sleep(Duration::from_millis(500));
740 reader = connect_consumer_client("reader", config)?;
741 continue;
742 }
743 };
744 if !rows.is_empty() {
745 for row in rows {
746 let id: i64 = row.get(0);
747 let event_json: String = row.get(1);
748 last_seen_id = id;
749
750 match MEvent::from_str_trim(&event_json) {
751 Ok(event) => {
752 apply_remote_event(event, host_id, &handler_registry, ®istry);
753 }
754 Err(err) => {
755 error!("Invalid postgres event row id={id}: {err}");
756 }
757 }
758 }
759 continue;
760 }
761
762 if !initial_done {
763 initial_done = true;
764 status.caught_up.store(true, Ordering::SeqCst);
765 info!("Postgres consumer caught up at event_id={last_seen_id}");
766 }
767
768 let mut notified = false;
769 let mut reconnect_listener = false;
770 {
771 let mut notifications = listener.notifications();
772 let mut iter = notifications.timeout_iter(Duration::from_millis(500));
773 match iter.next() {
774 Ok(Some(_n)) => {
775 notified = true;
776 }
777 Ok(None) => {}
778 Err(err) => {
779 warn!("Postgres LISTEN error: {err}; reconnecting listener");
780 reconnect_listener = true;
781 }
782 }
783 }
784 if reconnect_listener {
785 std::thread::sleep(Duration::from_millis(500));
786 listener = connect_listener_client(config)?;
787 continue;
789 }
790
791 if !notified {
792 trace!("Postgres consumer poll tick (no notification)");
793 }
794 }
795}
796
797fn connect_consumer_client(role: &str, config: &PostgresConfig) -> Result<Client, String> {
798 let mut backoff_ms = 250u64;
799 loop {
800 match connect_pg_client(config, role) {
801 Ok(mut client) => {
802 if let Err(err) = ensure_schema(&mut client, config) {
803 warn!(
804 "{}",
805 format_pg_error(&format!("ensure_schema({role})"), Some(&config.url), &err)
806 );
807 }
808 return Ok(client);
809 }
810 Err(err) => {
811 warn!("{err}");
812 std::thread::sleep(Duration::from_millis(backoff_ms));
813 backoff_ms = (backoff_ms * 2).min(5_000);
814 }
815 }
816 }
817}
818
819fn connect_listener_client(config: &PostgresConfig) -> Result<Client, String> {
820 let mut backoff_ms = 250u64;
821 loop {
822 let mut client = connect_consumer_client("listener", config)?;
823 match client.batch_execute(&format!("LISTEN {};", qi(&config.channel))) {
824 Ok(()) => return Ok(client),
825 Err(err) => {
826 warn!(
827 "{}",
828 format_pg_error("LISTEN(register)", Some(&config.url), &err)
829 );
830 std::thread::sleep(Duration::from_millis(backoff_ms));
831 backoff_ms = (backoff_ms * 2).min(5_000);
832 }
833 }
834 }
835}
836
837fn apply_remote_event(
838 event: MEvent,
839 host_id: &str,
840 handler_registry: &Arc<HandlerRegistry>,
841 registry: &Arc<StoreRegistry>,
842) {
843 let is_my_event = event.source_id.as_ref().is_some_and(|id| id == host_id);
844 if is_my_event {
845 return;
846 }
847
848 match event.change_type {
849 MEventType::SET => {
850 if let Some(parse) = handler_registry.get_item_parser(&event.item_type) {
851 match parse(event.item.clone()) {
852 Ok(item) => {
853 let store = registry.get_or_create(item.entity_type());
854 store.insert(item.id(), item);
855 }
856 Err(e) => {
857 let msg = e.to_string();
858 let short = msg
859 .find(", expected one of")
860 .map(|pos| msg[..pos].to_string())
861 .unwrap_or(msg);
862 error!("Failed to parse {}: {short}", event.item_type);
863 }
864 }
865 } else {
866 warn!("No parser for entity type: {}", event.item_type);
867 }
868 }
869 MEventType::DEL => {
870 if let Some(id) = event.item.get("id").and_then(|v| v.as_str()) {
871 let store = registry.get_or_create(&event.item_type);
872 store.remove(&id.into());
873 } else {
874 error!("DEL event missing id field: {:?}", event.item);
875 }
876 }
877 }
878}
879
880fn ensure_schema(client: &mut Client, config: &PostgresConfig) -> Result<(), ::postgres::Error> {
881 let table = qi(&config.table);
882 let idx_tx = qi(&format!("{}_tx_idx", config.table));
883 let idx_item = qi(&format!("{}_item_type_item_id_idx", config.table));
884 let idx_item_latest = qi(&format!("{}_item_latest_idx", config.table));
885 let idx_created = qi(&format!("{}_created_at_idx", config.table));
886 let trigger_fn = qi(&format!("{}_notify_insert_fn", config.table));
887 let trigger_name = qi(&format!("{}_notify_insert_trigger", config.table));
888
889 client.batch_execute(&format!(
890 "
891 CREATE TABLE IF NOT EXISTS {table} (
892 id BIGSERIAL PRIMARY KEY,
893 item_type TEXT NOT NULL,
894 item_id TEXT NOT NULL,
895 change_type TEXT NOT NULL,
896 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
897 tx TEXT NOT NULL,
898 source_id TEXT,
899 event JSONB NOT NULL
900 );
901 CREATE INDEX IF NOT EXISTS {idx_tx} ON {table} (tx);
902 CREATE INDEX IF NOT EXISTS {idx_item} ON {table} (item_type, item_id);
903 CREATE INDEX IF NOT EXISTS {idx_item_latest} ON {table} (item_type, item_id, id DESC);
904 CREATE INDEX IF NOT EXISTS {idx_created} ON {table} (created_at);
905 CREATE OR REPLACE FUNCTION {trigger_fn}() RETURNS trigger AS $$
906 BEGIN
907 PERFORM pg_notify('{channel}', NEW.id::text);
908 RETURN NEW;
909 END;
910 $$ LANGUAGE plpgsql;
911 DROP TRIGGER IF EXISTS {trigger_name} ON {table};
912 CREATE TRIGGER {trigger_name}
913 AFTER INSERT ON {table}
914 FOR EACH ROW
915 EXECUTE FUNCTION {trigger_fn}();
916 ",
917 channel = config.channel
918 ))?;
919
920 Ok(())
921}
922
923fn validate_ident(name: &str) -> Result<(), String> {
924 if name.is_empty() {
925 return Err("identifier cannot be empty".to_string());
926 }
927 let mut chars = name.chars();
928 let first = chars
929 .next()
930 .ok_or_else(|| "identifier cannot be empty".to_string())?;
931 if !(first == '_' || first.is_ascii_alphabetic()) {
932 return Err(format!(
933 "invalid identifier `{name}`: must start with letter or underscore"
934 ));
935 }
936 if !chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) {
937 return Err(format!(
938 "invalid identifier `{name}`: only letters, numbers, underscore are allowed"
939 ));
940 }
941 Ok(())
942}
943
944fn qi(name: &str) -> String {
945 format!("\"{name}\"")
946}
947
948fn row_to_persisted_event(row: ::postgres::Row) -> Result<PersistedEvent, String> {
949 let id: i64 = row.get(0);
950 let created_at: String = row.get(1);
951 let event_json: String = row.get(2);
952 let event = MEvent::from_str_trim(&event_json)
953 .map_err(|e| format!("invalid history event payload for id={id}: {e}"))?;
954 Ok(PersistedEvent {
955 id,
956 created_at,
957 event,
958 })
959}
960
961fn redact_pg_url(url: &str) -> String {
962 match url.rfind('@') {
963 Some(at) => {
964 let after_scheme = url.find("://").map(|idx| idx + 3).unwrap_or(0);
965 format!("{}***{}", &url[..after_scheme], &url[at..])
966 }
967 None => url.to_string(),
968 }
969}
970
971fn format_pg_connect_error(role: &str, url: &str, err: &postgres::Error) -> String {
972 format_pg_error(role, Some(url), err)
973}
974
975fn format_pg_error(role: &str, url: Option<&str>, err: &postgres::Error) -> String {
976 let mut msg = match url {
977 Some(url) => format!("{role} failed (dsn={}): {}", redact_pg_url(url), err),
978 None => format!("{role} failed: {err}"),
979 };
980 if let Some(db) = err.as_db_error() {
981 msg.push_str(&format!(
982 " [code={} severity={} message={}]",
983 db.code().code(),
984 db.severity(),
985 db.message()
986 ));
987 if let Some(detail) = db.detail() {
988 msg.push_str(&format!(" [detail={}]", detail));
989 }
990 if let Some(hint) = db.hint() {
991 msg.push_str(&format!(" [hint={}]", hint));
992 }
993 }
994 msg
995}
996
997fn connect_pg_client(config: &PostgresConfig, role: &str) -> Result<Client, String> {
998 let mut client_config = parse_pg_client_config(config, role)?;
999
1000 client_config.connect_timeout(Duration::from_secs(PG_CONNECT_TIMEOUT_SECS));
1002 client_config.keepalives(true);
1003 client_config.keepalives_idle(Duration::from_secs(PG_KEEPALIVE_IDLE_SECS));
1004 client_config.keepalives_interval(Duration::from_secs(PG_KEEPALIVE_INTERVAL_SECS));
1005 client_config.keepalives_retries(PG_KEEPALIVE_RETRIES);
1006 client_config.tcp_user_timeout(Duration::from_secs(PG_TCP_USER_TIMEOUT_SECS));
1010
1011 client_config
1012 .connect(NoTls)
1013 .map_err(|err| format_pg_connect_error(role, &config.url, &err))
1014}
1015
1016fn parse_pg_client_config(config: &PostgresConfig, role: &str) -> Result<PgClientConfig, String> {
1017 config.url.parse::<PgClientConfig>().map_err(|err| {
1018 format!(
1019 "postgres config parse failed ({role}, dsn={}): {err}",
1020 redact_pg_url(&config.url)
1021 )
1022 })
1023}