use super::types::QueuedUserMessage;
use crate::db::NotifyQueueRepository;
use uuid::Uuid;
const STALE_ROW_SECS: i64 = 24 * 60 * 60;
pub(crate) const MAX_ROW_AGE_SECS: i64 = 3 * STALE_ROW_SECS;
pub(crate) const BUSY_LANE_WRAPPER: &str =
"[queued while you were working — re-anchor to your current task after reading this]\n\n";
pub(crate) fn durable_context_text(context_text: &str) -> String {
strip_busy_wrapper(context_text).to_string()
}
pub(crate) fn strip_busy_wrapper(text: &str) -> &str {
let mut rest = text;
while let Some(stripped) = rest.strip_prefix(BUSY_LANE_WRAPPER) {
rest = stripped;
}
rest
}
pub(crate) fn wrap_busy_once(context_text: &str) -> String {
format!("{BUSY_LANE_WRAPPER}{}", strip_busy_wrapper(context_text))
}
fn now_unix() -> i64 {
chrono::Utc::now().timestamp()
}
fn repo() -> Option<NotifyQueueRepository> {
crate::db::global_pool().map(|p| NotifyQueueRepository::new(p.clone()))
}
fn spawn_if_runtime<F>(future: F, what: &'static str)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
handle.spawn(future);
}
Err(_) => {
tracing::debug!(
target: "background_task",
"No tokio runtime live: skipping {what} (durable parking unavailable)"
);
}
}
}
pub(crate) fn persist(session_id: Uuid, msg: &QueuedUserMessage) {
let Some(repo) = repo() else {
return;
};
let (context_text, display_text) = (
durable_context_text(&msg.context_text),
msg.display_text.clone(),
);
let (origin, bg_meta) = (msg.origin, msg.bg_meta.clone());
spawn_if_runtime(
async move {
if let Err(e) = repo
.record(
Uuid::new_v4(),
session_id,
&context_text,
&display_text,
origin,
bg_meta.as_ref(),
)
.await
{
tracing::error!(
target: "background_task",
"Could not persist undelivered push for session {session_id}: it rides \
the in-memory queue alone and the next restart will lose it: {e:#}"
);
}
},
"notify_queue persist",
);
}
pub(crate) async fn redeliver_persisted() -> usize {
let Some(repo) = repo() else {
return 0;
};
match repo.clear_dead_sessions().await {
Ok(reaped) if !reaped.is_empty() => {
tracing::info!(
target: "background_task",
"Boot notify queue: reaped={} row(s) whose session no longer exists",
reaped.len()
);
for row in reaped {
let age_h = now_unix().saturating_sub(row.created_at) / 3600;
let preview: String = row
.context_text
.replace(['\n', '\r'], " ")
.trim()
.chars()
.take(120)
.collect();
tracing::error!(
target: "background_task",
"Notify queue reaper: dropped dead-session push {} for session {} (age {}h, origin={:?}): {}",
row.id,
row.session_id,
age_h,
row.origin,
preview
);
}
}
Ok(_) => {}
Err(e) => tracing::warn!(
target: "background_task",
"Could not reap notify-queue rows for dead sessions: {e:#}"
),
}
let rows = match repo.all().await {
Ok(rows) => rows,
Err(e) => {
tracing::error!(
target: "background_task",
"Could not read persisted notify-queue rows: {e:#}"
);
return 0;
}
};
let mut count = 0usize;
for row in rows {
let msg = QueuedUserMessage {
context_text: row.context_text.clone(),
display_text: row.display_text.clone(),
origin: row.origin,
bg_meta: row.bg_meta.clone(),
};
if super::restart_recovery::deliver_or_park(row.session_id, msg)
&& let Err(e) = repo.clear(row.id).await
{
tracing::error!(
target: "background_task",
"Delivered persisted push {} but could not clear its row; it may be \
re-delivered after the next restart: {e:#}",
row.id
);
}
count += 1;
}
reap_stale_after_redelivery(&repo).await;
count
}
async fn reap_stale_after_redelivery(repo: &NotifyQueueRepository) {
let now = now_unix();
let cutoff = now.saturating_sub(MAX_ROW_AGE_SECS);
let max_age_hours = MAX_ROW_AGE_SECS / 3600;
match repo.reap_stale_unclaimed(cutoff).await {
Ok(reaped) if !reaped.is_empty() => {
tracing::info!(
target: "background_task",
"Boot notify queue: reaped {} stale unclaimed push(es) older than {}h",
reaped.len(),
max_age_hours
);
for row in reaped {
let age_h = now.saturating_sub(row.created_at) / 3600;
let clean_text = row.context_text.replace(['\n', '\r'], " ");
let preview: String = clean_text.trim().chars().take(120).collect();
tracing::error!(
target: "background_task",
"Notify queue reaper: dropped undeliverable push {} for unclaimed session {} (age {}h > {}h, origin={:?}): {}",
row.id,
row.session_id,
age_h,
max_age_hours,
row.origin,
preview
);
}
}
Ok(_) => {}
Err(e) => tracing::warn!(
target: "background_task",
"Could not reap stale unclaimed notify-queue rows: {e:#}"
),
}
}
const CLEAR_MATCH_MARGIN: std::time::Duration = std::time::Duration::from_secs(5);
const CLEAR_MATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(
super::restart_recovery::ROUTE_GRACE.as_secs() + CLEAR_MATCH_MARGIN.as_secs(),
);
const CLEAR_MATCH_STEP: std::time::Duration = std::time::Duration::from_millis(250);
pub(crate) fn clear_on_delivery(session_id: Uuid, msg: &QueuedUserMessage) {
let Some(repo) = repo() else {
return;
};
let context_text = durable_context_text(&msg.context_text);
let display_text = msg.display_text.clone();
spawn_if_runtime(
async move {
let retired = match retire_rows_until(
&repo,
session_id,
&context_text,
&display_text,
CLEAR_MATCH_WINDOW,
CLEAR_MATCH_STEP,
)
.await
{
Ok(n) => n,
Err(e) => {
tracing::warn!(
target: "background_task",
"Could not clear delivered push from the durable notify queue for \
session {session_id}: next boot may redeliver it (a duplicate, \
never a loss): {e:#}"
);
return;
}
};
if retired > 0 {
return;
}
match repo.all().await {
Ok(rows) if !rows.iter().any(|r| r.session_id == session_id) => {
tracing::debug!(
target: "background_task",
"Delivered push for session {session_id} had no durable row (already \
retired by a sibling consume site)"
);
}
Ok(rows) => {
let count = rows.iter().filter(|r| r.session_id == session_id).count();
tracing::warn!(
target: "background_task",
"Delivered push for session {session_id} matched NO durable row after \
{:?}, yet {count} row(s) are held for that session — the clear key does \
not match what was persisted; those rows survive to the next boot \
redelivery (a duplicate, never a loss)",
CLEAR_MATCH_WINDOW
);
}
Err(e) => tracing::warn!(
target: "background_task",
"Delivered push for session {session_id} retired no durable row and the \
queue could not be read to say why: {e:#}"
),
}
},
"notify_queue clear",
);
}
pub(crate) async fn retire_rows_until(
repo: &NotifyQueueRepository,
session_id: Uuid,
context_text: &str,
display_text: &str,
window: std::time::Duration,
step: std::time::Duration,
) -> anyhow::Result<usize> {
let deadline = tokio::time::Instant::now() + window;
let mut retired = 0usize;
loop {
retired += clear_matching_rows(repo, session_id, context_text, display_text).await?;
if tokio::time::Instant::now() >= deadline {
return Ok(retired);
}
tokio::time::sleep(step).await;
}
}
async fn clear_matching_rows(
repo: &NotifyQueueRepository,
session_id: Uuid,
context_text: &str,
display_text: &str,
) -> anyhow::Result<usize> {
let mut retired = 0usize;
for row in repo.all().await? {
if row.session_id != session_id || row.display_text != display_text {
continue;
}
if strip_busy_wrapper(&row.context_text) != context_text {
continue;
}
repo.clear(row.id).await?;
retired += 1;
}
Ok(retired)
}