use futures_util::StreamExt;
use std::collections::BTreeSet;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use stow_types::api::{EnqueueAdmission, EnqueueTicket};
use stow_types::pow::MAX_POW_DIFFICULTY;
use tokio::task::JoinHandle;
use zenwave::Client;
use crate::config::StowConfig;
const SUBMIT_CONCURRENCY: usize = 64;
const SOLVE_ATTEMPT_BUDGET: u64 = 1 << 22;
const ATTEMPT_HEADROOM_BITS: u32 = 2;
#[derive(Debug)]
pub struct AdmissionCollector {
seen: BTreeSet<String>,
queued: Vec<EnqueueAdmission>,
worker: Option<JoinHandle<()>>,
budget: Arc<AtomicU64>,
cancelled: Arc<AtomicBool>,
}
impl Default for AdmissionCollector {
fn default() -> Self {
Self {
seen: BTreeSet::new(),
queued: Vec::new(),
worker: None,
budget: Arc::new(AtomicU64::new(SOLVE_ATTEMPT_BUDGET)),
cancelled: Arc::new(AtomicBool::new(false)),
}
}
}
impl AdmissionCollector {
pub fn record(
&mut self,
config: &StowConfig,
admissions: impl IntoIterator<Item = EnqueueAdmission>,
) {
for admission in admissions {
if !self.seen.insert(admission.task_id.clone()) {
continue;
}
if admission.difficulty > MAX_POW_DIFFICULTY {
tracing::warn!(
task_id = %admission.task_id,
difficulty = admission.difficulty,
max = MAX_POW_DIFFICULTY,
"skipping enqueue admission above maximum difficulty"
);
continue;
}
self.queued.push(admission);
}
self.spawn_worker(config);
}
pub fn abandon(&mut self) {
self.cancelled.store(true, Ordering::Relaxed);
if let Some(worker) = self.worker.take() {
worker.abort();
}
if !self.queued.is_empty() {
tracing::debug!(
queued = self.queued.len(),
"leaving unredeemed miss admissions behind; a re-miss mints them again"
);
self.queued.clear();
}
}
fn spawn_worker(&mut self, config: &StowConfig) {
if self.worker.is_some() || self.queued.is_empty() {
return;
}
let batch = std::mem::take(&mut self.queued);
let config = config.clone();
let budget = Arc::clone(&self.budget);
let cancelled = Arc::clone(&self.cancelled);
self.worker = Some(tokio::spawn(async move {
redeem_batch(&config, batch, &budget, &cancelled).await;
}));
}
}
async fn redeem_batch(
config: &StowConfig,
batch: Vec<EnqueueAdmission>,
budget: &Arc<AtomicU64>,
cancelled: &Arc<AtomicBool>,
) {
let mut batch = batch;
let offset = solve_offset(batch.len());
batch.rotate_left(offset);
let tickets = solve_batch(batch, budget, cancelled).await;
futures_util::stream::iter(tickets)
.for_each_concurrent(SUBMIT_CONCURRENCY, |ticket| submit_ticket(config, ticket))
.await;
}
fn solve_offset(len: usize) -> usize {
if len == 0 {
return 0;
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
usize::try_from(nanos).unwrap_or(0) % len
}
async fn solve_batch(
batch: Vec<EnqueueAdmission>,
budget: &Arc<AtomicU64>,
cancelled: &Arc<AtomicBool>,
) -> Vec<EnqueueTicket> {
if batch.is_empty() {
return Vec::new();
}
let budget = Arc::clone(budget);
let cancelled = Arc::clone(cancelled);
match tokio::task::spawn_blocking(move || solve_all(&batch, &budget, &cancelled)).await {
Ok(tickets) => tickets,
Err(error) => {
tracing::warn!(%error, "enqueue admission solver failed");
Vec::new()
}
}
}
fn solve_all(
batch: &[EnqueueAdmission],
budget: &AtomicU64,
cancelled: &AtomicBool,
) -> Vec<EnqueueTicket> {
let mut tickets = Vec::with_capacity(batch.len());
for admission in batch {
if cancelled.load(Ordering::Relaxed) {
break;
}
let Some(nonce) = solve_nonce(admission, budget, cancelled) else {
tracing::warn!(
task_id = %admission.task_id,
difficulty = admission.difficulty,
"enqueue admission unsolved within nonce bound; abandoning"
);
continue;
};
tickets.push(EnqueueTicket {
task_id: admission.task_id.clone(),
challenge: admission.challenge.clone(),
nonce,
request: admission.request.clone(),
});
}
tickets
}
fn solve_nonce(
admission: &EnqueueAdmission,
budget: &AtomicU64,
cancelled: &AtomicBool,
) -> Option<u64> {
if admission.difficulty == 0 {
return Some(0);
}
let want = 1u64
.checked_shl(admission.difficulty + ATTEMPT_HEADROOM_BITS)
.unwrap_or(u64::MAX);
let granted = take_attempts(budget, want);
if granted == 0 {
tracing::debug!(
task_id = %admission.task_id,
difficulty = admission.difficulty,
"admission proof-of-work budget spent; leaving this preheat request unredeemed"
);
return None;
}
let (nonce, spent) = scan_nonces(admission, granted, cancelled);
budget.fetch_add(granted - spent, Ordering::Relaxed);
nonce
}
fn take_attempts(budget: &AtomicU64, want: u64) -> u64 {
let mut remaining = budget.load(Ordering::Relaxed);
loop {
let granted = want.min(remaining);
if granted == 0 {
return 0;
}
match budget.compare_exchange_weak(
remaining,
remaining - granted,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return granted,
Err(actual) => remaining = actual,
}
}
}
const CANCEL_CHECK_INTERVAL: u64 = 4096;
fn scan_nonces(
admission: &EnqueueAdmission,
attempts: u64,
cancelled: &AtomicBool,
) -> (Option<u64>, u64) {
for nonce in 0..attempts {
if nonce % CANCEL_CHECK_INTERVAL == 0 && cancelled.load(Ordering::Relaxed) {
return (None, nonce);
}
if stow_types::pow::enqueue_pow_zero_bits(&admission.task_id, &admission.challenge, nonce)
>= admission.difficulty
{
return (Some(nonce), nonce + 1);
}
}
(None, attempts)
}
async fn submit_ticket(config: &StowConfig, ticket: EnqueueTicket) {
let url = format!("{}/api/v1/enqueue", config.edge_url.trim_end_matches('/'));
let mut client = crate::edge_client::client(config);
let result = client
.post(&url)
.and_then(|request| request.json_body(&ticket));
match result {
Ok(request) => match request.await {
Ok(_) => {
tracing::debug!(task_id = %ticket.task_id, "redeemed enqueue admission");
}
Err(error) => {
tracing::warn!(
task_id = %ticket.task_id,
%error,
"enqueue admission post failed"
);
}
},
Err(error) => {
tracing::warn!(
task_id = %ticket.task_id,
%error,
"failed to build enqueue admission request"
);
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use stow_types::api::{EnqueueAdmission, EnqueueRequest, EnqueueSource};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use super::{AdmissionCollector, SOLVE_ATTEMPT_BUDGET, solve_nonce};
use crate::config::{StowConfig, VerifyMode};
fn test_request() -> EnqueueRequest {
EnqueueRequest {
crate_name: "serde".parse().expect("crate name"),
version: "1.0.0".parse().expect("version"),
features_json: stow_types::identity::FeaturesJson::canonicalize(Vec::new())
.expect("features"),
target: "x86_64-unknown-linux-gnu".parse().expect("target"),
rustc_version: "1.92.0".parse().expect("rustc"),
downloads: 0,
source: EnqueueSource::CacheMiss,
depends_on: Vec::new(),
preserve_lockfile: false,
}
}
fn test_admission(task_id: &str, difficulty: u32) -> EnqueueAdmission {
EnqueueAdmission {
task_id: task_id.to_owned(),
challenge: "abcdef0123456789".to_owned(),
difficulty,
request: test_request(),
}
}
fn full_budget() -> AtomicU64 {
AtomicU64::new(SOLVE_ATTEMPT_BUDGET)
}
fn running() -> AtomicBool {
AtomicBool::new(false)
}
#[test]
fn solver_finds_nonce_at_difficulty_eight() {
let admission = test_admission("serde-1.0.0-deadbeef-x86_64_unknown_linux_gnu-1_92_0", 8);
let budget = full_budget();
let nonce = solve_nonce(&admission, &budget, &running()).expect("nonce found within bound");
assert!(
stow_types::pow::enqueue_pow_zero_bits(&admission.task_id, &admission.challenge, nonce,)
>= 8
);
}
#[test]
fn solver_respects_the_attempt_bound() {
let admission = test_admission("task-that-cannot-solve", 8);
let budget = AtomicU64::new(0);
assert!(solve_nonce(&admission, &budget, &running()).is_none());
}
#[test]
fn an_exhausted_budget_stops_the_solver() {
let budget = AtomicU64::new(0);
let batch: Vec<EnqueueAdmission> = (0..8)
.map(|index| test_admission(&format!("task-{index}"), 8))
.collect();
assert!(super::solve_all(&batch, &budget, &running()).is_empty());
assert_eq!(budget.load(Ordering::Relaxed), 0);
}
#[test]
fn unused_attempts_return_to_the_budget() {
let budget = full_budget();
let batch: Vec<EnqueueAdmission> = (0..16)
.map(|index| test_admission(&format!("task-{index}"), 4))
.collect();
let tickets = super::solve_all(&batch, &budget, &running());
assert_eq!(tickets.len(), 15);
let spent = SOLVE_ATTEMPT_BUDGET - budget.load(Ordering::Relaxed);
assert!(
spent < 400,
"spent {spent} attempts on sixteen 4-bit admissions"
);
}
#[test]
fn the_solve_offset_stays_inside_the_batch() {
assert_eq!(super::solve_offset(0), 0);
for len in 1..64 {
assert!(super::solve_offset(len) < len, "offset escaped len {len}");
}
}
#[test]
fn rotating_the_batch_keeps_every_admission() {
let mut batch: Vec<EnqueueAdmission> = (0..10)
.map(|index| test_admission(&format!("task-{index}"), 0))
.collect();
let offset = super::solve_offset(batch.len());
batch.rotate_left(offset);
let mut ids: Vec<&str> = batch.iter().map(|a| a.task_id.as_str()).collect();
ids.sort_unstable();
assert_eq!(ids.len(), 10);
assert_eq!(ids[0], "task-0");
assert_eq!(ids[9], "task-9");
}
#[test]
fn a_free_admission_costs_no_budget() {
let budget = full_budget();
assert_eq!(
solve_nonce(&test_admission("task", 0), &budget, &running()),
Some(0)
);
assert_eq!(budget.load(Ordering::Relaxed), SOLVE_ATTEMPT_BUDGET);
}
fn test_config() -> StowConfig {
StowConfig {
edge_url: "http://127.0.0.1:9".to_owned(),
registry_base_url: "http://127.0.0.1:9/v2/water-rs/stow-cache".to_owned(),
cache_dir: std::path::PathBuf::from("/tmp/stow-admission-test"),
request_timeout: Duration::from_millis(50),
negative_cache_ttl: Duration::from_secs(1),
circuit_reset_after: Duration::from_secs(1),
circuit_trip_threshold: 1,
artifact_cache_max_bytes: 1,
index_refresh_interval: Duration::from_secs(1),
verify_mode: VerifyMode::GithubCi,
state_db_pool: StowConfig::default_state_db_pool(),
trust_material: std::sync::Arc::default(),
}
}
#[tokio::test]
async fn collector_deduplicates_by_task_id() {
let config = test_config();
let mut collector = AdmissionCollector::default();
collector.record(&config, [test_admission("task-1", 0)]);
collector.record(&config, [test_admission("task-1", 0)]);
collector.record(&config, [test_admission("task-2", 0)]);
assert_eq!(collector.seen.len(), 2);
collector.abandon();
}
#[tokio::test]
async fn one_solver_covers_the_whole_batch() {
let batch: Vec<EnqueueAdmission> = (0..257)
.map(|index| test_admission(&format!("task-{index}"), 0))
.collect();
let budget = Arc::new(AtomicU64::new(SOLVE_ATTEMPT_BUDGET));
let tickets = super::solve_batch(batch, &budget, &Arc::new(AtomicBool::new(false))).await;
assert_eq!(tickets.len(), 257);
assert!(tickets.iter().all(|ticket| ticket.nonce == 0));
}
#[tokio::test]
async fn abandoning_does_not_wait_for_the_solver() {
let config = test_config();
let mut collector = AdmissionCollector::default();
collector.record(&config, [test_admission("task-hard", 24)]);
let start = std::time::Instant::now();
collector.abandon();
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_millis(100),
"abandoning the worker took {elapsed:?}"
);
}
#[test]
fn a_cancelled_scan_stops_where_it_is() {
let budget = full_budget();
let cancelled = AtomicBool::new(true);
let batch = [test_admission("task-hard", 24)];
let start = std::time::Instant::now();
let tickets = super::solve_all(&batch, &budget, &cancelled);
let elapsed = start.elapsed();
assert!(tickets.is_empty());
assert_eq!(budget.load(Ordering::Relaxed), SOLVE_ATTEMPT_BUDGET);
assert!(
elapsed < Duration::from_millis(100),
"scan took {elapsed:?}"
);
}
#[tokio::test]
async fn collector_skips_difficulty_above_the_cap() {
let config = test_config();
let mut collector = AdmissionCollector::default();
collector.record(&config, [test_admission("task-hard", 25)]);
assert_eq!(collector.queued.len(), 0);
assert!(collector.worker.is_none());
collector.abandon();
}
}