use std::time::Duration;
use fraiseql_core::{
cache::feed::{
CHANNEL, CacheEvictor, Eviction, OutboundQueue, Received, decode, encode, encode_hello,
},
db::postgres::{NotifyConnector, NotifySession},
};
use tracing::{info, warn};
pub struct CacheFeedParts {
pub queue: OutboundQueue,
pub evictor: CacheEvictor,
}
const MIN_BACKOFF: Duration = Duration::from_millis(200);
const MAX_BACKOFF: Duration = Duration::from_secs(30);
enum Ended {
Disconnected,
Closed,
}
pub async fn run(connector: NotifyConnector, mut parts: CacheFeedParts, instance: String) {
let mut backoff = MIN_BACKOFF;
let mut connected_before = false;
loop {
match listening_session(&connector, &instance).await {
Ok(mut session) => {
backoff = MIN_BACKOFF;
if connected_before {
metrics::counter!("fraiseql_cache_feed_reconnects_total").increment(1);
}
connected_before = true;
parts.evictor.resume();
metrics::gauge!("fraiseql_cache_feed_connected").set(1.0);
info!(channel = CHANNEL, "Query result cache: listening to other servers' writes");
let ended = relay(&mut session, &mut parts, &instance).await;
parts.evictor.suspend();
metrics::gauge!("fraiseql_cache_feed_connected").set(0.0);
if matches!(ended, Ended::Closed) {
return;
}
warn!(
"Query result cache: the connection to other servers' writes was lost; \
nothing is cached until it is back"
);
},
Err(error) => {
warn!(
%error,
retry_in_ms = backoff.as_millis(),
"Query result cache: cannot listen to other servers' writes; nothing is \
cached until it can"
);
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF);
},
}
}
}
async fn listening_session(
connector: &NotifyConnector,
instance: &str,
) -> fraiseql_core::error::Result<NotifySession> {
super::notify_session::verified_session(
connector,
&[CHANNEL],
CHANNEL,
&encode_hello(instance),
|n| matches!(decode(&n.payload), Received::Hello { from } if from == instance),
)
.await
}
async fn relay(session: &mut NotifySession, parts: &mut CacheFeedParts, instance: &str) -> Ended {
let lost = outgoing(&parts.queue, Vec::new());
if !lost.is_empty() && !send(session, parts, instance, lost).await {
return Ended::Disconnected;
}
loop {
tokio::select! {
notification = session.recv() => match notification {
Some(n) => apply(&parts.evictor, &n.payload, instance),
None => return Ended::Disconnected,
},
batch = parts.queue.next_batch() => match batch {
Some(batch) => {
let batch = outgoing(&parts.queue, batch);
if !send(session, parts, instance, batch).await {
return Ended::Disconnected;
}
},
None => return Ended::Closed,
},
}
}
}
pub(super) fn outgoing(queue: &OutboundQueue, batch: Vec<Eviction>) -> Vec<Eviction> {
if queue.take_overflow() {
metrics::counter!("fraiseql_cache_feed_lost_total").increment(1);
vec![Eviction::Everything]
} else {
batch
}
}
async fn send(
session: &NotifySession,
parts: &CacheFeedParts,
instance: &str,
batch: Vec<Eviction>,
) -> bool {
for payload in encode(instance, batch) {
if let Err(error) = session.notify(CHANNEL, &payload).await {
warn!(%error, "Query result cache: an eviction could not be sent to other servers");
parts.queue.mark_lost();
return false;
}
metrics::counter!("fraiseql_cache_feed_sent_total").increment(1);
}
true
}
fn apply(evictor: &CacheEvictor, payload: &str, instance: &str) {
let Received::Evictions { from, evictions } = decode(payload) else {
return;
};
if from == instance {
return;
}
metrics::counter!("fraiseql_cache_feed_received_total").increment(1);
for eviction in &evictions {
if let Err(error) = evictor.apply(eviction) {
warn!(%error, "Query result cache: another server's eviction could not be applied");
let _ = evictor.apply(&Eviction::Everything);
}
}
}
#[cfg(test)]
#[path = "cache_feed_tests.rs"]
mod cache_feed_tests;