From 1c39b9965886fecbfbdf52106c2dfe4296dfdc2b Mon Sep 17 00:00:00 2001 From: crashkeys.dev Date: Tue, 09 Dec 2025 18:13:13 +0000 Subject: [PATCH] actor: implemented quote cycle manager. --- src/storage.rs | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file(s) changed, 97 insertion(s)(+), 3 deletion(s)(-) diff --git a/src/storage.rs b/src/storage.rs --- a/src/storage.rs +++ b/src/storage.rs @@ -1,5 +1,10 @@ use kameo::prelude::*; +use crate::storage::{ + queue::{DequeueQuote, EnqueueQuotes}, + source::SourceQuotes, +}; + mod rng { use rand::SeedableRng; @@ -185,7 +190,8 @@ } } } -struct QuoteCycle { +#[derive(Actor)] +pub struct QuoteCycle { rng: rng::PrngState, source_manager: ActorRef, queue_manager: ActorRef, @@ -213,9 +219,56 @@ } } } -mod test { - use crate::storage::QuoteCycle; +/// A message to [QuoteCycle] to fetch one more quote from its storage. +pub struct FetchQuote; + +impl Message for QuoteCycle +where + S: source::SourceManager, + Q: queue::QueueManager, +{ + type Reply = Result; + + async fn handle( + &mut self, + _msg: FetchQuote, + _ctx: &mut Context, + ) -> Self::Reply { + // 1. We query our queue storage for the next quote + if let Some(next_quote) = self.queue_manager.ask(DequeueQuote).await.map_err(|_| ())? { + // if there is a quote, we simply return it and move on + return Ok(next_quote); + } + + // 2. Otherwise, we must repopulate the queue through our source + let mut refreshed_quotes = self + .source_manager + .ask(SourceQuotes) + .await + .map_err(|_| ())?; + + // 3. We shuffle the newly-sourced quotes + self.rng.shuffle_slice(&mut refreshed_quotes); + let refreshed_quotes = refreshed_quotes; // No longer mutable + + // TODO: Perhaps we should assert that the new quotes are non-empty? + // 4. We enqueue the newly-sourced quotes... + let _ = self + .queue_manager + .ask(EnqueueQuotes(refreshed_quotes)) + .await + .map_err(|_| ())?; + + // 5. and, finally, we return the first among them. + match self.queue_manager.ask(DequeueQuote).await { + Ok(Some(q)) => Ok(q), + Ok(None) => panic!("Newly-enqueued quotes should never be empty"), + Err(_) => Err(()), + } + } +} +mod test { #[tokio::test] async fn memory_queue() { use super::Quote; @@ -269,6 +322,47 @@ // we map the strings into quotes instead .map(String::from) .collect::>() .as_slice(), + ); + } + + #[tokio::test] + async fn memory_cycle() { + use std::{collections::HashMap, ops::AddAssign}; + + use super::FetchQuote; + use super::QuoteCycle; + use super::queue::*; + use super::source::*; + use kameo::prelude::*; + + let sample_quotes = ["Minie", "Miney", "Moe"]; + let cycle = { + let source = MemorySourceManager::spawn(MemorySourceManager::new(&sample_quotes)); + let queue = MemoryQueueStorage::spawn(MemoryQueueStorage::new()); + + QuoteCycle::spawn(QuoteCycle::with_thread_rng(source, queue)) + }; + + // We loop over `sample_quotes` twice to simulate the queue being exhausted fully, then re-sourced + // Since the `cycle` manager will shuffle the quote sequence, we will verify that each + // quote appears *exactly* `LOOPS` times throughout these iterations. + const LOOPS: usize = 3; + let mut quote_counts = HashMap::new(); + for _ in 0..(sample_quotes.len() * LOOPS) { + let next_quote = cycle.ask(FetchQuote).await.unwrap(); + quote_counts + .entry(next_quote.get().to_owned()) + .or_insert(0) + .add_assign(1); + } + + let quote_counts = quote_counts; // no longer mut + assert!( + // Note: technically speaking, different quotes could contain equivalent strings, + // which would make this test fail; a "more proper" invariant check would ensure + // verify that all counts are a multiple of the amount of times `sample_quotes` was chained, + // and that the sum of all counts equals the total number of times a `FetchQuote` message was sent. + quote_counts.into_values().into_iter().all(|c| c == LOOPS) ); } } -- tangled.sh