-
-
Notifications
You must be signed in to change notification settings - Fork 7
feat(taskbroker): Add Canary Task Sending to Taskbroker #752
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| use std::collections::BTreeMap; | ||
|
|
||
| use anyhow::{Context, Error, anyhow}; | ||
| use chrono::Utc; | ||
| use prost::Message as _; | ||
| use sentry_protos::taskbroker::v1::{OnAttemptsExceeded, TaskActivation}; | ||
| use serde::Serialize; | ||
| use tracing::info; | ||
| use uuid::Uuid; | ||
|
|
||
| use crate::config::Config; | ||
| use crate::kafka::deserialize_activation::bucket_from_id; | ||
| use crate::store::activation::{Activation, ActivationStatus}; | ||
| use crate::store::traits::ActivationStore; | ||
|
|
||
| const CANARY_NAMESPACE: &str = "internal"; | ||
| const CANARY_TASKNAME: &str = "canary_task"; | ||
| const CANARY_PROCESSING_DEADLINE_SECONDS: i32 = 10; | ||
|
|
||
| #[derive(Serialize)] | ||
| struct CanaryParameters { | ||
| args: Vec<()>, | ||
| kwargs: BTreeMap<String, ()>, | ||
| } | ||
|
|
||
| /// Add the configured number of canary tasks to the activation store for every | ||
| /// application in `worker_map`. | ||
| pub async fn enqueue(config: &Config, store: &dyn ActivationStore) -> Result<(), Error> { | ||
| if config.canary_tasks == 0 || config.worker_map.is_empty() { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| let topic = config | ||
| .consumable_topics() | ||
| .map_err(|error| anyhow!(error.to_string()))? | ||
| .first() | ||
| .map(|(name, _)| *name) | ||
| .ok_or_else(|| anyhow!("No consumable topic configured"))?; | ||
|
|
||
| let activations = build_activations(config, topic)?; | ||
| let batch_size = config.store.insert_batch_max_length.max(1); | ||
| let mut stored = 0; | ||
|
|
||
| for batch in activations.chunks(batch_size) { | ||
| stored += store.store(batch).await?; | ||
| } | ||
|
|
||
| if stored > 0 { | ||
| info!( | ||
| count = stored, | ||
| worker_pools = config.worker_map.len(), | ||
| "Stored startup canary tasks" | ||
| ); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| fn build_activations(config: &Config, topic: &str) -> Result<Vec<Activation>, Error> { | ||
| let parameters_bytes = rmp_serde::to_vec_named(&CanaryParameters { | ||
| args: Vec::new(), | ||
| kwargs: BTreeMap::new(), | ||
| }) | ||
| .context("Failed to serialize canary task parameters")?; | ||
|
|
||
| let mut activations = Vec::new(); | ||
|
|
||
| for application in config.worker_map.keys() { | ||
| for offset in 0..config.canary_tasks { | ||
| let now = Utc::now(); | ||
| let id = Uuid::new_v4().to_string(); | ||
| let task_activation = TaskActivation { | ||
| id: id.clone(), | ||
| application: Some(application.clone()), | ||
| namespace: CANARY_NAMESPACE.to_owned(), | ||
| taskname: CANARY_TASKNAME.to_owned(), | ||
| parameters_bytes: parameters_bytes.clone(), | ||
| processing_deadline_duration: CANARY_PROCESSING_DEADLINE_SECONDS as u64, | ||
| received_at: Some(prost_types::Timestamp { | ||
| seconds: now.timestamp(), | ||
| nanos: now.timestamp_subsec_nanos() as i32, | ||
| }), | ||
| ..Default::default() | ||
| }; | ||
|
|
||
| activations.push(Activation { | ||
| id: id.clone(), | ||
| application: application.clone(), | ||
| namespace: CANARY_NAMESPACE.to_owned(), | ||
| taskname: CANARY_TASKNAME.to_owned(), | ||
| activation: task_activation.encode_to_vec(), | ||
| status: ActivationStatus::Pending, | ||
| topic: topic.to_owned(), | ||
| partition: 0, | ||
| offset: offset.into(), | ||
| added_at: now, | ||
| received_at: now, | ||
| processing_attempts: 0, | ||
| processing_deadline_duration: CANARY_PROCESSING_DEADLINE_SECONDS, | ||
| expires_at: None, | ||
| delay_until: None, | ||
| processing_deadline: None, | ||
| claim_expires_at: None, | ||
| on_attempts_exceeded: OnAttemptsExceeded::Discard, | ||
| at_most_once: false, | ||
| bucket: bucket_from_id(&id), | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| Ok(activations) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use std::collections::{BTreeMap, HashMap}; | ||
|
|
||
| use prost::Message as _; | ||
| use sentry_protos::taskbroker::v1::TaskActivation; | ||
| use serde::Deserialize; | ||
|
|
||
| use super::{CANARY_NAMESPACE, CANARY_TASKNAME, build_activations}; | ||
| use crate::config::Config; | ||
|
|
||
| #[derive(Debug, Deserialize, PartialEq)] | ||
| struct CanaryParameters { | ||
| args: Vec<()>, | ||
| kwargs: BTreeMap<String, ()>, | ||
| } | ||
|
|
||
| #[test] | ||
| fn builds_configured_number_of_canaries_per_worker_pool() { | ||
| let config = Config { | ||
| canary_tasks: 3, | ||
| worker_map: BTreeMap::from([ | ||
| ("launchpad".to_owned(), "http://launchpad".to_owned()), | ||
| ("sentry".to_owned(), "http://sentry".to_owned()), | ||
| ]), | ||
| ..Default::default() | ||
| }; | ||
|
|
||
| let activations = build_activations(&config, "taskworker").unwrap(); | ||
| assert_eq!(activations.len(), 6); | ||
|
|
||
| let mut counts = HashMap::new(); | ||
| for activation in activations { | ||
| *counts.entry(activation.application.clone()).or_insert(0) += 1; | ||
| assert_eq!(activation.namespace, CANARY_NAMESPACE); | ||
| assert_eq!(activation.taskname, CANARY_TASKNAME); | ||
| assert_eq!(activation.topic, "taskworker"); | ||
| assert_eq!(activation.processing_deadline_duration, 10); | ||
|
|
||
| let task = TaskActivation::decode(activation.activation.as_slice()).unwrap(); | ||
| assert_eq!(task.id, activation.id); | ||
| assert_eq!( | ||
| task.application.as_deref(), | ||
| Some(activation.application.as_str()) | ||
| ); | ||
| assert_eq!(task.namespace, CANARY_NAMESPACE); | ||
| assert_eq!(task.taskname, CANARY_TASKNAME); | ||
| assert_eq!( | ||
| rmp_serde::from_slice::<CanaryParameters>(&task.parameters_bytes).unwrap(), | ||
| CanaryParameters { | ||
| args: Vec::new(), | ||
| kwargs: BTreeMap::new(), | ||
| } | ||
| ); | ||
| } | ||
|
|
||
| assert_eq!(counts.get("launchpad"), Some(&3)); | ||
| assert_eq!(counts.get("sentry"), Some(&3)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn builds_no_canaries_when_disabled() { | ||
| let config = Config::default(); | ||
| assert!(build_activations(&config, "taskworker").unwrap().is_empty()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,7 @@ use tonic::transport::Server; | |
| use tonic_health::ServingStatus; | ||
| use tracing::{debug, error, info, warn}; | ||
|
|
||
| use taskbroker::canary_tasks; | ||
| use taskbroker::config::store::DatabaseAdapter; | ||
| use taskbroker::config::{Config, DeliveryMode}; | ||
| use taskbroker::fetch::FetchPool; | ||
|
|
@@ -108,7 +109,10 @@ async fn main() -> Result<(), Error> { | |
| Err(err) => error!("Failed to run full vacuum on startup: {:?}", err), | ||
| } | ||
| } | ||
| // Get startup time after migrations and vacuum | ||
|
|
||
| canary_tasks::enqueue(&config, store.as_ref()).await?; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: A transient database error when enqueuing canary tasks during startup will cause the entire broker to fail to start due to error propagation from Suggested FixInstead of using the Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||
|
|
||
| // Get startup time after migrations, vacuum, and startup canaries | ||
| let startup_time = Utc::now(); | ||
|
|
||
| // Taskbroker exposes a grpc.v1.health endpoint. We use upkeep to track the health | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Each replica enqueues duplicate canaries
Medium Severity
Every taskbroker process calls
canary_tasks::enqueueon boot. With a shared Postgres activation store and horizontal scaling, each replica inserts anothercanary_tasks × worker_mapbatch with new UUIDs, so the same startup event can fan out far more internal canary work than the configured count intends.Additional Locations (1)
src/canary_tasks.rs#L27-L46Reviewed by Cursor Bugbot for commit b95c1a0. Configure here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm okay with this for now, but this is another good reason to send canary tasks into a topic shared by the entire pool.