use std::time::Duration;
use fraiseql_core::{
cache::tviews_feed::{CHANNEL, TviewsListener},
db::postgres::{NotifyConnector, NotifySession},
error::{FraiseQLError, Result},
};
use tracing::{info, warn};
const HEARTBEAT: Duration = Duration::from_secs(5);
use super::notify_session::PROBE_TIMEOUT;
const MIN_BACKOFF: Duration = Duration::from_millis(200);
const MAX_BACKOFF: Duration = Duration::from_secs(30);
const FEED_GAPS: &str = "SELECT tviews.pg_tviews_feed_gaps()";
const CANNOT_TELL: [&str; 2] = ["55000", "42883"];
#[derive(Debug)]
enum GapRead {
Count(i64),
CannotTell(String),
}
fn gap_read(result: Result<i64>) -> Option<GapRead> {
match result {
Ok(count) => Some(GapRead::Count(count)),
Err(FraiseQLError::Database {
message,
sql_state: Some(state),
..
}) if CANNOT_TELL.contains(&state.as_str()) => Some(GapRead::CannotTell(message)),
Err(_) => None,
}
}
#[derive(Debug, PartialEq, Eq)]
enum Beat {
Keep,
Clear,
End,
}
#[derive(Debug, Default)]
struct Gaps {
seen: Option<i64>,
told: bool,
}
impl Gaps {
const fn session(&mut self) {
self.seen = None;
}
fn moved(&mut self, read: GapRead) -> bool {
match read {
GapRead::Count(count) => {
let moved = self.seen.is_some_and(|seen| seen != count);
self.seen = Some(count);
moved
},
GapRead::CannotTell(reason) => {
if self.first_cannot_tell() {
warn!(
%reason,
"Query result cache: pg_tviews cannot count the transactions it commits \
unpublished (preload a release with tviews.pg_tviews_feed_gaps()); a \
write this server does not run, committed while the NOTIFY queue is \
full, stays cached until the feed reconnects or the TTL expires"
);
}
false
},
}
}
fn beat(&mut self, read: Option<GapRead>) -> Beat {
match read.map(|read| self.moved(read)) {
None => Beat::End,
Some(true) => Beat::Clear,
Some(false) => Beat::Keep,
}
}
const fn first_cannot_tell(&mut self) -> bool {
let first = !self.told;
self.told = true;
first
}
}
async fn read_gaps(session: &NotifySession) -> Option<GapRead> {
tokio::time::timeout(PROBE_TIMEOUT, session.query_bigint(FEED_GAPS))
.await
.ok()
.and_then(gap_read)
}
pub async fn run(connector: NotifyConnector, listener: TviewsListener, instance: String) {
let probe = format!("fraiseql_tviews_probe_{instance}");
let mut backoff = MIN_BACKOFF;
let mut gaps = Gaps::default();
loop {
match listening_session(&connector, &probe).await {
Ok(mut session) => {
gaps.session();
if gaps.beat(read_gaps(&session).await) == Beat::End {
warn!(
retry_in_ms = backoff.as_millis(),
"Query result cache: cannot read tviews.pg_tviews_feed_gaps(); writes \
this server does not run are not heard until it can"
);
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF);
continue;
}
backoff = MIN_BACKOFF;
listener.listening();
metrics::gauge!("fraiseql_tviews_feed_connected").set(1.0);
info!(channel = CHANNEL, "Query result cache: listening to pg_tviews' changes");
relay(&mut session, &listener, &probe, &mut gaps).await;
listener.lost();
metrics::gauge!("fraiseql_tviews_feed_connected").set(0.0);
warn!(
"Query result cache: the connection to pg_tviews' changes was lost; writes \
this server does not run are not heard until it is back (the cache was \
cleared)"
);
},
Err(error) => {
warn!(
%error,
retry_in_ms = backoff.as_millis(),
"Query result cache: cannot listen to pg_tviews' changes; writes this \
server does not run are not heard until it can"
);
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF);
},
}
}
}
async fn relay(
session: &mut NotifySession,
listener: &TviewsListener,
probe: &str,
gaps: &mut Gaps,
) {
let mut heartbeat = tokio::time::interval(HEARTBEAT);
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
heartbeat.tick().await;
let mut probe_outstanding = false;
loop {
tokio::select! {
notification = session.recv() => match notification {
Some(n) if n.channel == CHANNEL => {
metrics::counter!("fraiseql_tviews_feed_received_total").increment(1);
listener.receive(&n.payload);
},
Some(n) if n.channel == probe => probe_outstanding = false,
Some(_) => {},
None => return,
},
_ = heartbeat.tick() => {
if probe_outstanding {
warn!("Query result cache: pg_tviews' change feed stopped delivering");
return;
}
match tokio::time::timeout(PROBE_TIMEOUT, session.notify(probe, "")).await {
Ok(Ok(())) => probe_outstanding = true,
_ => return,
}
match gaps.beat(read_gaps(session).await) {
Beat::Keep => {},
Beat::Clear => {
metrics::counter!("fraiseql_tviews_feed_gaps_total").increment(1);
warn!(
"Query result cache: pg_tviews committed transactions without \
publishing them (its NOTIFY queue was over 90 % full); the cache \
was cleared"
);
listener.missed();
},
Beat::End => return,
}
},
}
}
}
async fn listening_session(
connector: &NotifyConnector,
probe: &str,
) -> fraiseql_core::error::Result<NotifySession> {
super::notify_session::verified_session(connector, &[CHANNEL, probe], probe, "", |n| {
n.channel == probe
})
.await
}
#[cfg(test)]
#[path = "tviews_feed_tests.rs"]
mod tviews_feed_tests;