Skip to main content

myko_server/
postgres.rs

1//! PostgreSQL producer and consumer for the cell-based server.
2//!
3//! Architecture:
4//! - Producer persists `MEvent` rows into a durable table.
5//! - Consumer replays table rows (catch-up), then follows new inserts via LISTEN/NOTIFY.
6
7use 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;
29/// TCP_USER_TIMEOUT: tear down a connection whose sent data stays unacked for
30/// this long. Unlike keepalives — which only probe an *idle* socket — this
31/// fires on a peer that died mid-write, so a consumer killed mid-snapshot (OOM)
32/// has its Postgres backend reaped promptly instead of lingering in ClientWrite
33/// holding the events-table lock and stalling every subsequent boot's snapshot
34/// until the catch-up timeout (the lv-8c2f "boot convoy").
35const PG_TCP_USER_TIMEOUT_SECS: u64 = 30;
36const PG_PRODUCER_MAX_BATCH: usize = 256;
37
38/// PostgreSQL configuration.
39#[derive(Debug, Clone)]
40pub struct PostgresConfig {
41    /// PostgreSQL connection URL.
42    pub url: String,
43    /// Events table name.
44    pub table: String,
45    /// LISTEN/NOTIFY channel name.
46    pub channel: String,
47}
48
49/// Persisted event row fetched from Postgres.
50#[derive(Debug, Clone)]
51pub struct PersistedEvent {
52    pub id: i64,
53    pub created_at: String,
54    pub event: MEvent,
55}
56
57impl PostgresConfig {
58    /// Create config from environment.
59    ///
60    /// - `MYKO_POSTGRES_URL` (required)
61    /// - `MYKO_POSTGRES_TABLE` (optional, default `myko_events`)
62    /// - `MYKO_POSTGRES_CHANNEL` (optional, default `myko_events_notify`)
63    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
76/// Read API for durable event history (windback/replay providers).
77pub struct PostgresHistoryStore {
78    config: PostgresConfig,
79}
80
81impl PostgresHistoryStore {
82    /// Create a history store and ensure schema exists.
83    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    /// Read events with `id > after_id`, ascending.
90    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    /// Read events with `id > after_id` and `created_at <= until`, ascending.
105    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        // NOTE(ts): Validate timestamp format to prevent SQL injection
113        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    /// Read events in a time window, ascending.
135    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
155/// HistoryReplayProvider backed by PostgresHistoryStore.
156pub 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        // NOTE(ts): Validate timestamp format to prevent SQL injection
178        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        // NOTE(ts): Use DISTINCT ON to get the latest event per entity as of the
190        // timestamp, same approach as the bootstrap snapshot but time-bounded.
191        // Only include entities whose latest event is a SET (not deleted).
192        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/// Handle to the PostgreSQL producer.
246#[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    /// Persist an event. Enqueues to the background producer thread and
256    /// returns immediately. Returns Err only if the channel is full (backpressure).
257    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
296/// PostgreSQL producer — background thread with fail-fast error propagation.
297pub struct CellPostgresProducer {
298    handle: PostgresProducerHandle,
299}
300
301impl CellPostgresProducer {
302    /// Create a new PostgreSQL producer.
303    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    /// Get a shareable persister handle.
324    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        // NOTE(ts): Collect a batch — start with any retry events, then drain the channel.
339        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, // channel closed
346            }
347        }
348        // NOTE(ts): Drain additional ready events up to the batch limit.
349        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                    // NOTE(ts): Back off before retrying to avoid tight-looping on persistent failures.
402                    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    // NOTE(ts): Single event — skip transaction overhead.
449    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    // NOTE(ts): Build a multi-row INSERT for the batch within a single transaction.
481    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, &param_refs)?;
526    txn.commit()?;
527
528    Ok(())
529}
530
531/// Shared status for startup catch-up.
532#[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    /// Check if startup catch-up completed.
549    pub fn is_caught_up(&self) -> bool {
550        self.caught_up.load(Ordering::SeqCst)
551    }
552
553    /// Check if catch-up has failed.
554    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    /// Block until caught up or timeout.
566    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
589/// PostgreSQL consumer that replays + tails events.
590pub struct CellPostgresConsumer {
591    catch_up_status: Arc<CatchUpStatus>,
592    _handle: std::thread::JoinHandle<()>,
593}
594
595impl CellPostgresConsumer {
596    /// Start a PostgreSQL event consumer thread.
597    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    /// Check if startup catch-up is complete.
632    pub fn is_caught_up(&self) -> bool {
633        self.catch_up_status.is_caught_up()
634    }
635
636    /// Wait for startup catch-up with timeout.
637    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    // `latest` is the newest event per item; the outer `change_type = 'SET'`
664    // then drops items whose latest event is a DEL — i.e. deleted items, whose
665    // tombstone row would otherwise be fetched and applied only to no-op in
666    // apply_remote_event (~48% of rows on a long-lived table). The filter MUST
667    // be on the outer query, after DISTINCT ON has picked each item's latest
668    // row: filtering DEL inside the CTE would instead take a deleted item's
669    // latest *non-DEL* row and wrongly resurrect it. Mirrors the history-replay
670    // snapshot query above.
671    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    // Stream the snapshot with `query_raw` (server-side portal, fetched in
688    // batches) and apply each event as it arrives, rather than collecting the
689    // entire latest-per-item result into one `Vec<Row>` first — boot memory was
690    // proportional to (distinct live items) x (event JSON size), a multi-GiB
691    // spike on large tables. The RowIter borrows `reader` for the duration;
692    // apply_remote_event only touches the registries, never `reader`.
693    // Scoped so the RowIter's mutable borrow of `reader` is released before the
694    // tail catch-up loop below reuses (and reconnects) `reader`.
695    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, &registry);
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, &registry);
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            // Listener dropped; run an immediate catch-up query pass before waiting again.
788            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    // Avoid default 2h keepalive-idle so long-lived idle sockets are detected quickly.
1001    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    // Reap a peer that died mid-write, which keepalives (idle-only) miss — see
1007    // PG_TCP_USER_TIMEOUT_SECS. Applies to every role; on a live connection the
1008    // data is acked normally so it never fires.
1009    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}