use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use awaken_contract::contract::mailbox::{RunDispatch, RunDispatchStatus};
use awaken_contract::contract::storage::StorageError;
use tokio::sync::RwLock;
use super::{codec, keys, kv_helpers, metrics, ops_write};
fn status_key(status: RunDispatchStatus) -> String {
format!("{status:?}")
}
#[derive(Default)]
pub struct DispatchIndex {
by_id: HashMap<String, IndexedDispatch>,
by_thread: HashMap<String, Vec<String>>,
by_status: HashMap<String, HashSet<String>>,
}
struct IndexedDispatch {
dispatch: RunDispatch,
revision: Option<u64>,
}
impl DispatchIndex {
pub fn upsert(&mut self, dispatch: RunDispatch) {
self.upsert_inner(dispatch, None, false);
}
pub fn upsert_with_revision(&mut self, dispatch: RunDispatch, revision: u64) {
self.upsert_inner(dispatch, Some(revision), false);
}
pub fn force_upsert(&mut self, dispatch: RunDispatch) {
self.upsert_inner(dispatch, None, true);
}
fn upsert_inner(&mut self, dispatch: RunDispatch, revision: Option<u64>, force: bool) {
let id = dispatch.dispatch_id.clone();
if let Some(prev) = self.by_id.get(&id) {
if !force {
if let (Some(prev_revision), Some(incoming_revision)) = (prev.revision, revision)
&& incoming_revision < prev_revision
{
return;
}
if revision.is_none() && dispatch.updated_at < prev.dispatch.updated_at {
return;
}
}
if let Some(set) = self.by_status.get_mut(&status_key(prev.dispatch.status)) {
set.remove(&id);
}
} else {
self.by_thread
.entry(dispatch.thread_id.clone())
.or_default()
.push(id.clone());
}
self.by_status
.entry(status_key(dispatch.status))
.or_default()
.insert(id.clone());
self.by_id
.insert(id, IndexedDispatch { dispatch, revision });
}
pub fn remove(&mut self, dispatch_id: &str) {
self.remove_inner(dispatch_id, None);
}
pub fn remove_with_revision(&mut self, dispatch_id: &str, revision: u64) {
self.remove_inner(dispatch_id, Some(revision));
}
fn remove_inner(&mut self, dispatch_id: &str, revision: Option<u64>) {
if let Some(incoming_revision) = revision
&& let Some(indexed) = self.by_id.get(dispatch_id)
&& let Some(current_revision) = indexed.revision
&& incoming_revision < current_revision
{
return;
}
if let Some(indexed) = self.by_id.remove(dispatch_id) {
let dispatch = indexed.dispatch;
if let Some(set) = self.by_status.get_mut(&status_key(dispatch.status)) {
set.remove(dispatch_id);
}
if let Some(ids) = self.by_thread.get_mut(&dispatch.thread_id) {
ids.retain(|id| id != dispatch_id);
if ids.is_empty() {
self.by_thread.remove(&dispatch.thread_id);
}
}
}
}
pub fn get(&self, dispatch_id: &str) -> Option<&RunDispatch> {
self.by_id.get(dispatch_id).map(|indexed| &indexed.dispatch)
}
pub fn list_by_thread(
&self,
thread_id: &str,
status_filter: Option<&[RunDispatchStatus]>,
) -> Vec<RunDispatch> {
let Some(ids) = self.by_thread.get(thread_id) else {
return Vec::new();
};
ids.iter()
.filter_map(|id| self.by_id.get(id).map(|indexed| indexed.dispatch.clone()))
.filter(|d| match status_filter {
Some(filter) => filter.contains(&d.status),
None => true,
})
.collect()
}
pub fn queued_thread_ids(&self) -> Vec<String> {
let Some(queued_ids) = self.by_status.get(&status_key(RunDispatchStatus::Queued)) else {
return Vec::new();
};
let mut threads: HashSet<String> = HashSet::new();
for id in queued_ids {
if let Some(indexed) = self.by_id.get(id) {
threads.insert(indexed.dispatch.thread_id.clone());
}
}
threads.into_iter().collect()
}
pub fn count_by_status(&self, status: RunDispatchStatus) -> usize {
self.by_status
.get(&status_key(status))
.map_or(0, HashSet::len)
}
pub fn available_queued(&self, now: u64) -> Vec<RunDispatch> {
let Some(queued_ids) = self.by_status.get(&status_key(RunDispatchStatus::Queued)) else {
return Vec::new();
};
queued_ids
.iter()
.filter_map(|id| self.by_id.get(id).map(|indexed| &indexed.dispatch))
.filter(|d| d.available_at <= now)
.cloned()
.collect()
}
}
pub fn spawn_watcher(
kv: async_nats::jetstream::kv::Store,
kv_thread_index: async_nats::jetstream::kv::Store,
index: Arc<RwLock<DispatchIndex>>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
ready_tx: tokio::sync::oneshot::Sender<Result<(), String>>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut watcher = match kv.watch_all().await {
Ok(w) => w,
Err(e) => {
let _ = ready_tx.send(Err(e.to_string()));
tracing::warn!(error = %e, "nats_mailbox watch_all failed");
return;
}
};
let initial_scan = initial_scan(&kv, &kv_thread_index, &index)
.await
.map_err(|e| e.to_string());
if let Err(ref e) = initial_scan {
tracing::warn!(error = %e, "nats_mailbox watcher initial scan failed");
}
let _ = ready_tx.send(initial_scan);
use futures::StreamExt;
loop {
tokio::select! {
changed = shutdown_rx.changed() => {
if changed.is_err() || *shutdown_rx.borrow() { break; }
}
entry = watcher.next() => {
match entry {
Some(Ok(entry)) => apply_entry(&kv_thread_index, &index, entry).await,
Some(Err(e)) => {
tracing::warn!(error = %e, "nats_mailbox watcher error");
}
None => break,
}
}
}
}
})
}
async fn initial_scan(
kv: &async_nats::jetstream::kv::Store,
kv_thread_index: &async_nats::jetstream::kv::Store,
index: &Arc<RwLock<DispatchIndex>>,
) -> Result<(), StorageError> {
use futures::StreamExt;
let started = std::time::Instant::now();
let mut keys = kv
.keys()
.await
.map_err(|e| StorageError::Io(format!("kv dispatch keys: {e}")))?;
let mut key_count = 0usize;
while let Some(key_result) = keys.next().await {
let key = match key_result {
Ok(k) => k,
Err(_) => continue,
};
key_count += 1;
if let Ok(Some(entry)) = kv.entry(&key).await
&& !kv_helpers::is_tombstone(&entry)
&& let Ok(dispatch) = codec::decode(&entry.value)
{
reconcile_thread_index(kv_thread_index, &dispatch).await?;
index
.write()
.await
.upsert_with_revision(dispatch, entry.revision);
}
}
metrics::record_watcher_initial_scan(key_count, started.elapsed());
Ok(())
}
async fn apply_entry(
kv_thread_index: &async_nats::jetstream::kv::Store,
index: &Arc<RwLock<DispatchIndex>>,
entry: async_nats::jetstream::kv::Entry,
) {
if kv_helpers::is_tombstone(&entry) {
if let Some(id) = keys::dispatch_id_from_key(&entry.key) {
index
.write()
.await
.remove_with_revision(&id, entry.revision);
}
return;
}
if let Ok(dispatch) = codec::decode(&entry.value) {
if let Err(error) = reconcile_thread_index(kv_thread_index, &dispatch).await {
tracing::warn!(
thread_id = %dispatch.thread_id,
dispatch_id = %dispatch.dispatch_id,
error = %error,
"failed to maintain nats_mailbox thread index from watcher"
);
}
index
.write()
.await
.upsert_with_revision(dispatch, entry.revision);
}
}
async fn reconcile_thread_index(
kv_thread_index: &async_nats::jetstream::kv::Store,
dispatch: &RunDispatch,
) -> Result<(), StorageError> {
if is_terminal(dispatch.status) {
ops_write::remove_thread_index_from_bucket(
kv_thread_index,
&dispatch.thread_id,
&dispatch.dispatch_id,
)
.await
} else {
ops_write::append_thread_index_to_bucket(
kv_thread_index,
&dispatch.thread_id,
&dispatch.dispatch_id,
)
.await
}
}
fn is_terminal(status: RunDispatchStatus) -> bool {
matches!(
status,
RunDispatchStatus::Acked
| RunDispatchStatus::Cancelled
| RunDispatchStatus::Superseded
| RunDispatchStatus::DeadLetter
)
}
#[cfg(test)]
mod tests {
use super::*;
fn dispatch(id: &str, status: RunDispatchStatus, updated_at: u64) -> RunDispatch {
RunDispatch {
dispatch_id: id.to_string(),
thread_id: "thread".to_string(),
run_id: format!("{id}-run"),
priority: 128,
dedupe_key: None,
dispatch_epoch: 0,
status,
available_at: 0,
attempt_count: 0,
max_attempts: 3,
last_error: None,
claim_token: None,
claimed_by: None,
lease_until: None,
dispatch_instance_id: None,
run_status: None,
termination: None,
run_response: None,
run_error: None,
completed_at: None,
created_at: 0,
updated_at,
}
}
#[test]
fn revision_guard_rejects_stale_watcher_events() {
let mut index = DispatchIndex::default();
index.upsert_with_revision(dispatch("d1", RunDispatchStatus::Claimed, 20), 20);
index.upsert_with_revision(dispatch("d1", RunDispatchStatus::Queued, 10), 10);
assert_eq!(
index.get("d1").map(|dispatch| dispatch.status),
Some(RunDispatchStatus::Claimed)
);
assert_eq!(index.count_by_status(RunDispatchStatus::Claimed), 1);
assert_eq!(index.count_by_status(RunDispatchStatus::Queued), 0);
}
#[test]
fn revision_guard_rejects_stale_delete_events() {
let mut index = DispatchIndex::default();
index.upsert_with_revision(dispatch("d1", RunDispatchStatus::Queued, 20), 20);
index.remove_with_revision("d1", 10);
assert!(index.get("d1").is_some());
assert_eq!(index.count_by_status(RunDispatchStatus::Queued), 1);
}
}