Skip to content

Commit 800b83f

Browse files
authored
Merge pull request #474 from ProjectASAP/feat/plan-version-staged-activation
feat(plan): add versioned staged activation
2 parents 6254e6e + 7b89b1c commit 800b83f

14 files changed

Lines changed: 712 additions & 29 deletions

File tree

‎control_plane/proto/backend_plan.proto‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,8 @@ message BackendPlan {
191191
map<uint64, Materialization> materializations = 3;
192192
repeated RoutingEntry routing = 4;
193193
repeated MonitorSpec monitors = 5;
194+
uint64 plan_version = 6;
195+
uint64 activation_unix_ms = 7;
196+
optional uint64 expiry_unix_ms = 8;
197+
string backend_compat = 9;
194198
}

‎control_plane/src/backend_client.rs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,35 @@ impl BackendClient {
391391
Err(classify_http_status(status, body, "PhysicalPlan POST"))
392392
}
393393
}
394+
395+
pub async fn activate_physical_plan(
396+
&self,
397+
plan_id: u64,
398+
plan_version: u64,
399+
) -> std::result::Result<(), BackendPostError> {
400+
let url = format!("{}/activate", derive_physical_plan_url(&self.endpoint));
401+
let response = self
402+
.http
403+
.post(&url)
404+
.json(&serde_json::json!({
405+
"plan_id": plan_id,
406+
"plan_version": plan_version,
407+
}))
408+
.send()
409+
.await
410+
.map_err(classify_reqwest_error)?;
411+
let status = response.status();
412+
if status.is_success() {
413+
Ok(())
414+
} else {
415+
let body = response.text().await.unwrap_or_default();
416+
Err(classify_http_status(
417+
status,
418+
body,
419+
"PhysicalPlan activation POST",
420+
))
421+
}
422+
}
394423
}
395424

396425
fn derive_physical_plan_url(endpoint: &str) -> String {

‎control_plane/src/backend_plan/from_stage_config.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ pub fn from_stage_config(
119119
Ok(BackendPlan {
120120
plan_id,
121121
generated_at_unix_ms,
122+
plan_version: 1,
123+
activation_unix_ms: generated_at_unix_ms,
124+
expiry_unix_ms: None,
125+
backend_compat: super::BACKEND_COMPAT.into(),
122126
materializations,
123127
routing,
124128
monitors: plan_monitors,

‎control_plane/src/backend_plan/mod.rs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ use asap_types::{AggregationType, MonitorSpec, PolicyFingerprint};
3939
use prost::Message as _;
4040
use thiserror::Error;
4141

42+
pub const BACKEND_COMPAT: &str = "asap-query-backend.v1";
43+
4244
use crate::physical::runtime_capability::Capability;
4345
use asap_types::enums::WindowKind;
4446
use planner_types::post_asap::{
@@ -82,6 +84,27 @@ pub enum ValidationError {
8284
IncompatibleRoute { fingerprint: u64 },
8385
#[error("stale plan generation: incoming={incoming}, active={active}")]
8486
StaleGeneration { incoming: u64, active: u64 },
87+
#[error("stale plan version for plan {plan_id}: incoming={incoming}, active={active}")]
88+
StalePlanVersion {
89+
plan_id: u64,
90+
incoming: u64,
91+
active: u64,
92+
},
93+
#[error("plan {plan_id} version {plan_version} was reused with different content")]
94+
ReusedPlanVersion { plan_id: u64, plan_version: u64 },
95+
#[error("non-bootstrap plan must have a non-zero plan version")]
96+
ZeroPlanVersion,
97+
#[error("non-bootstrap plan must have an activation time")]
98+
MissingActivation,
99+
#[error("plan expiry {expiry} is not after activation {activation}")]
100+
InvalidExpiry { activation: u64, expiry: u64 },
101+
#[error("non-bootstrap plan must declare backend compatibility")]
102+
MissingBackendCompat,
103+
#[error("unsupported backend compatibility `{actual}`; expected `{expected}`")]
104+
UnsupportedBackendCompat {
105+
actual: String,
106+
expected: &'static str,
107+
},
85108
}
86109

87110
// ── WindowSpec ───────────────────────────────────────────────────────────────
@@ -772,6 +795,10 @@ pub struct BackendPlan {
772795
/// existing convention for the pre-`BackendPlan` wire format).
773796
pub plan_id: u64,
774797
pub generated_at_unix_ms: u64,
798+
pub plan_version: u64,
799+
pub activation_unix_ms: u64,
800+
pub expiry_unix_ms: Option<u64>,
801+
pub backend_compat: String,
775802
pub materializations: HashMap<PolicyFingerprint, Materialization>,
776803
pub routing: Vec<RoutingEntry>,
777804
pub monitors: Vec<MonitorSpec>,
@@ -782,6 +809,10 @@ impl From<&BackendPlan> for proto::BackendPlan {
782809
proto::BackendPlan {
783810
plan_id: p.plan_id,
784811
generated_at_unix_ms: p.generated_at_unix_ms,
812+
plan_version: p.plan_version,
813+
activation_unix_ms: p.activation_unix_ms,
814+
expiry_unix_ms: p.expiry_unix_ms,
815+
backend_compat: p.backend_compat.clone(),
785816
materializations: p
786817
.materializations
787818
.iter()
@@ -809,6 +840,10 @@ impl TryFrom<proto::BackendPlan> for BackendPlan {
809840
Ok(BackendPlan {
810841
plan_id: p.plan_id,
811842
generated_at_unix_ms: p.generated_at_unix_ms,
843+
plan_version: p.plan_version,
844+
activation_unix_ms: p.activation_unix_ms,
845+
expiry_unix_ms: p.expiry_unix_ms,
846+
backend_compat: p.backend_compat,
812847
materializations,
813848
routing,
814849
monitors: p.monitors.into_iter().map(Into::into).collect(),
@@ -832,6 +867,31 @@ impl BackendPlan {
832867
/// Validate cross-references and invariants required before a decoded
833868
/// plan may become visible to ingest or query readers.
834869
pub fn validate(&self) -> Result<(), ValidationError> {
870+
if self.plan_id != 0 {
871+
if self.plan_version == 0 {
872+
return Err(ValidationError::ZeroPlanVersion);
873+
}
874+
if self.activation_unix_ms == 0 {
875+
return Err(ValidationError::MissingActivation);
876+
}
877+
if self.backend_compat.trim().is_empty() {
878+
return Err(ValidationError::MissingBackendCompat);
879+
}
880+
if self.backend_compat != BACKEND_COMPAT {
881+
return Err(ValidationError::UnsupportedBackendCompat {
882+
actual: self.backend_compat.clone(),
883+
expected: BACKEND_COMPAT,
884+
});
885+
}
886+
if let Some(expiry) = self.expiry_unix_ms {
887+
if expiry <= self.activation_unix_ms {
888+
return Err(ValidationError::InvalidExpiry {
889+
activation: self.activation_unix_ms,
890+
expiry,
891+
});
892+
}
893+
}
894+
}
835895
for (key, materialization) in &self.materializations {
836896
if *key != materialization.fingerprint {
837897
return Err(ValidationError::FingerprintMismatch {
@@ -976,6 +1036,10 @@ mod tests {
9761036
BackendPlan {
9771037
plan_id: 42,
9781038
generated_at_unix_ms: 1_735_000_000_000,
1039+
plan_version: 1,
1040+
activation_unix_ms: 1_735_000_000_000,
1041+
expiry_unix_ms: None,
1042+
backend_compat: "asap-query-backend.v1".into(),
9791043
materializations,
9801044
routing: vec![
9811045
RoutingEntry {

‎control_plane/src/emit/backend_push.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,9 @@ async fn push_documents_coupled(
247247
plan_id: crate::backend_plan::BackendPlan::decode(&plan_bytes)
248248
.map(|plan| plan.plan_id)
249249
.unwrap_or_default(),
250+
plan_version: crate::backend_plan::BackendPlan::decode(&plan_bytes)
251+
.map(|plan| plan.plan_version)
252+
.unwrap_or_default(),
250253
entries: Default::default(),
251254
};
252255

@@ -454,7 +457,11 @@ async fn push_cumulative_entries(
454457
let precompute_plan = PrecomputePlan {
455458
envelope: PlanEnvelope {
456459
plan_id,
460+
plan_version: 1,
457461
generated_at_unix_ms,
462+
activation_unix_ms: generated_at_unix_ms,
463+
expiry_unix_ms: None,
464+
backend_compat: "asap-query-backend.v1".into(),
458465
planner_revision: crate::physical::compiler::PLANNER_REVISION.into(),
459466
capability_snapshot_id: "replanner".into(),
460467
},

‎control_plane/src/main.rs‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,10 @@ struct CompileAndPublishPhysicalPlanRequest {
595595
evidence: HashMap<String, physical::compiler::TopKMembershipEvidence>,
596596
planner_revision: String,
597597
max_evidence_age_ms: u64,
598+
plan_version: u64,
599+
activation_unix_ms: u64,
600+
expiry_unix_ms: Option<u64>,
601+
backend_compat: String,
598602
#[serde(default = "default_physical_plan_timeout_ms")]
599603
apply_timeout_ms: u64,
600604
}
@@ -606,6 +610,8 @@ fn default_physical_plan_timeout_ms() -> u64 {
606610
#[derive(Debug, Serialize)]
607611
struct CompileAndPublishPhysicalPlanResponse {
608612
plan_id: u64,
613+
plan_version: u64,
614+
status: &'static str,
609615
generated_at_unix_ms: u64,
610616
collector_ids: Vec<String>,
611617
}
@@ -667,9 +673,36 @@ async fn handle_compile_and_publish_physical_plan(
667673
)
668674
.into_response();
669675
}
676+
let now = std::time::SystemTime::now()
677+
.duration_since(std::time::UNIX_EPOCH)
678+
.unwrap_or_default()
679+
.as_millis() as u64;
680+
let activation_wait = bundle.envelope.activation_unix_ms.saturating_sub(now);
681+
if activation_wait > apply_timeout.as_millis() as u64 {
682+
return (
683+
StatusCode::GATEWAY_TIMEOUT,
684+
"activation time exceeds apply_timeout_ms; backend remains staged".to_string(),
685+
)
686+
.into_response();
687+
}
688+
if activation_wait > 0 {
689+
tokio::time::sleep(Duration::from_millis(activation_wait)).await;
690+
}
691+
if let Err(error) = backend
692+
.activate_physical_plan(bundle.envelope.plan_id, bundle.envelope.plan_version)
693+
.await
694+
{
695+
return (
696+
StatusCode::BAD_GATEWAY,
697+
format!("backend physical-plan activation failed: {error}"),
698+
)
699+
.into_response();
700+
}
670701

671702
Json(CompileAndPublishPhysicalPlanResponse {
672703
plan_id: bundle.envelope.plan_id,
704+
plan_version: bundle.envelope.plan_version,
705+
status: "active",
673706
generated_at_unix_ms: bundle.envelope.generated_at_unix_ms,
674707
collector_ids,
675708
})
@@ -693,6 +726,18 @@ fn compile_physical_plan_request(
693726
"max_evidence_age_ms and apply_timeout_ms must be non-zero".to_string(),
694727
));
695728
}
729+
if request.plan_version == 0
730+
|| request.activation_unix_ms == 0
731+
|| request.backend_compat != control_plane::backend_plan::BACKEND_COMPAT
732+
|| request
733+
.expiry_unix_ms
734+
.is_some_and(|expiry| expiry <= request.activation_unix_ms)
735+
{
736+
return Err((
737+
StatusCode::UNPROCESSABLE_ENTITY,
738+
"plan_version, activation_unix_ms, backend_compat and expiry are invalid".to_string(),
739+
));
740+
}
696741

697742
let now = std::time::SystemTime::now()
698743
.duration_since(std::time::UNIX_EPOCH)
@@ -748,6 +793,10 @@ fn compile_physical_plan_request(
748793
capability_snapshot_id: request.capability_snapshot_id,
749794
observed_at_unix_ms: now,
750795
max_evidence_age_ms: request.max_evidence_age_ms,
796+
plan_version: request.plan_version,
797+
activation_unix_ms: request.activation_unix_ms,
798+
expiry_unix_ms: request.expiry_unix_ms,
799+
backend_compat: request.backend_compat,
751800
},
752801
) {
753802
Ok(bundle) => bundle,

0 commit comments

Comments
 (0)