use crate::api::StorageApi;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
const DEFAULT_PERIOD_SECS: u64 = 30;
const DEFAULT_BATCH: usize = 64;
#[derive(Debug, Clone, Copy)]
pub struct ReconcileConfig {
pub enabled: bool,
pub period: Duration,
pub batch: usize,
}
impl Default for ReconcileConfig {
fn default() -> Self {
Self {
enabled: true,
period: Duration::from_secs(DEFAULT_PERIOD_SECS),
batch: DEFAULT_BATCH,
}
}
}
impl ReconcileConfig {
pub fn from_env() -> Self {
let d = Self::default();
let enabled = match std::env::var("QRUSTY_COUNTER_RECONCILE_ENABLED") {
Ok(v) => !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no"),
Err(_) => d.enabled,
};
let period = std::env::var("QRUSTY_COUNTER_RECONCILE_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|&s| s > 0)
.map(Duration::from_secs)
.unwrap_or(d.period);
let batch = std::env::var("QRUSTY_COUNTER_RECONCILE_BATCH")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|&b| b > 0)
.unwrap_or(d.batch);
Self {
enabled,
period,
batch,
}
}
}
fn select_batch(total: usize, cursor: usize, batch: usize) -> (Vec<usize>, usize) {
if total == 0 || batch == 0 {
return (Vec::new(), 0);
}
let take = batch.min(total);
let start = cursor % total;
let indices: Vec<usize> = (0..take).map(|i| (start + i) % total).collect();
let next = (start + take) % total;
(indices, next)
}
pub async fn counter_reconciler(
storage: Arc<dyn StorageApi>,
repairs: Arc<AtomicU64>,
cfg: ReconcileConfig,
) {
let mut interval = tokio::time::interval(cfg.period);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut cursor: usize = 0;
loop {
interval.tick().await;
if let Err(e) = sweep_once(storage.as_ref(), &repairs, cfg.batch, &mut cursor).await {
tracing::error!("counter_reconciler: sweep failed: {}", e);
}
}
}
async fn sweep_once(
storage: &dyn StorageApi,
repairs: &AtomicU64,
batch: usize,
cursor: &mut usize,
) -> anyhow::Result<u64> {
let queues = storage.list_queues().await?;
let (indices, next) = select_batch(queues.len(), *cursor, batch);
*cursor = next;
let mut repaired = 0u64;
for i in indices {
let name = &queues[i];
let diag = match storage.diagnose_queue(name).await? {
Some(d) => d,
None => continue, };
let expected_available = diag.scanned_available;
let expected_locked = diag.scanned_locked_active + diag.scanned_locked_expired;
if diag.counter_available == expected_available && diag.counter_locked == expected_locked {
continue;
}
let before = (diag.counter_available, diag.counter_locked);
match storage.reseed_queue_counters(name).await? {
Some(after) => {
repaired += 1;
repairs.fetch_add(1, Ordering::Relaxed);
tracing::warn!(
event_type = "counter_drift_repaired",
queue = %name,
before_available = before.0,
before_locked = before.1,
after_available = after.0,
after_locked = after.1,
discrepancies = ?diag.discrepancies,
"counter drift detected and repaired by reseed",
);
}
None => {
tracing::warn!(
queue = %name,
discrepancies = ?diag.discrepancies,
"counter drift detected but backend does not support reseed",
);
}
}
}
Ok(repaired)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory_storage::MemoryStorage;
use crate::message::{Message, Priority};
use chrono::Utc;
#[test]
fn select_batch_wraps_and_advances() {
assert_eq!(select_batch(10, 0, 3), (vec![0, 1, 2], 3));
assert_eq!(select_batch(10, 3, 3), (vec![3, 4, 5], 6));
assert_eq!(select_batch(10, 8, 3), (vec![8, 9, 0], 1));
}
#[test]
fn select_batch_batch_ge_total_takes_all_once() {
let (idx, next) = select_batch(4, 2, 10);
assert_eq!(idx, vec![2, 3, 0, 1]); assert_eq!(next, 2);
}
#[test]
fn select_batch_empty_and_zero() {
assert_eq!(select_batch(0, 5, 4), (Vec::new(), 0));
assert_eq!(select_batch(5, 0, 0), (Vec::new(), 0));
}
fn msg(queue: &str, payload: &str) -> Message {
Message {
id: uuid::Uuid::new_v4().to_string(),
queue: queue.to_string(),
payload: payload.to_string(),
priority: Priority::Numeric(100),
created_at: Utc::now(),
locked_until: None,
locked_by: None,
retry_count: 0,
max_retries: 3,
payload_ref: None,
payload_hash: None,
}
}
#[tokio::test]
async fn sweep_repairs_injected_drift() {
let storage = MemoryStorage::new();
storage.push(msg("q", "a")).await.unwrap();
storage.push(msg("q", "b")).await.unwrap();
let s = storage.get_queue_stats("q").await.unwrap();
assert_eq!((s.available, s.locked), (2, 0));
storage.__test_set_queue_counters("q", 99, 7).await;
let s = storage.get_queue_stats("q").await.unwrap();
assert_eq!((s.available, s.locked), (99, 7), "drift injected");
let repairs = AtomicU64::new(0);
let mut cursor = 0usize;
let n = sweep_once(&storage, &repairs, 64, &mut cursor)
.await
.unwrap();
assert_eq!(n, 1, "exactly one queue repaired");
assert_eq!(repairs.load(Ordering::Relaxed), 1);
let s = storage.get_queue_stats("q").await.unwrap();
assert_eq!(
(s.available, s.locked),
(2, 0),
"counters self-healed to the true population"
);
}
#[tokio::test]
async fn sweep_ignores_expired_unswept_lock() {
let storage = MemoryStorage::new();
storage.push(msg("q", "a")).await.unwrap();
let _ = storage.pop("q", "c1", 0).await.unwrap().unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let s = storage.get_queue_stats("q").await.unwrap();
assert_eq!(
(s.available, s.locked),
(0, 1),
"counters correct per SYS-0018"
);
let repairs = AtomicU64::new(0);
let mut cursor = 0usize;
let n = sweep_once(&storage, &repairs, 64, &mut cursor)
.await
.unwrap();
assert_eq!(n, 0, "expired-unswept lock is not drift — no repair");
assert_eq!(repairs.load(Ordering::Relaxed), 0);
let s = storage.get_queue_stats("q").await.unwrap();
assert_eq!(
(s.available, s.locked),
(0, 1),
"counters untouched by sweep"
);
}
#[tokio::test]
async fn sweep_noop_when_accurate() {
let storage = MemoryStorage::new();
storage.push(msg("q", "a")).await.unwrap();
let repairs = AtomicU64::new(0);
let mut cursor = 0usize;
let n = sweep_once(&storage, &repairs, 64, &mut cursor)
.await
.unwrap();
assert_eq!(n, 0);
assert_eq!(repairs.load(Ordering::Relaxed), 0);
}
}