1use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
9pub enum PlacementKind {
10 Local,
12 Remote,
14 Colocated,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
20pub struct PlacementObservation {
21 pub role: String,
23 pub kind: PlacementKind,
25 pub endpoint: Option<String>,
27 pub region: Option<String>,
29 pub colocated_with: Option<String>,
31}
32
33impl PlacementObservation {
34 #[must_use]
36 pub fn local(role: impl Into<String>) -> Self {
37 Self {
38 role: role.into(),
39 kind: PlacementKind::Local,
40 endpoint: None,
41 region: None,
42 colocated_with: None,
43 }
44 }
45
46 #[must_use]
48 pub fn remote(role: impl Into<String>, endpoint: impl Into<String>) -> Self {
49 Self {
50 role: role.into(),
51 kind: PlacementKind::Remote,
52 endpoint: Some(endpoint.into()),
53 region: None,
54 colocated_with: None,
55 }
56 }
57
58 #[must_use]
60 pub fn colocated(role: impl Into<String>, peer: impl Into<String>) -> Self {
61 Self {
62 role: role.into(),
63 kind: PlacementKind::Colocated,
64 endpoint: None,
65 region: None,
66 colocated_with: Some(peer.into()),
67 }
68 }
69
70 #[must_use]
72 pub fn with_region(mut self, region: impl Into<String>) -> Self {
73 self.region = Some(region.into());
74 self
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
80pub enum TransportBoundaryKind {
81 InProcess,
83 SharedMemory,
85 Network,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
91pub struct TransportBoundaryObservation {
92 pub from_role: String,
94 pub to_role: String,
96 pub boundary: TransportBoundaryKind,
98 pub cross_region: bool,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum TransitionArtifactPhase {
106 Staged,
108 Admitted,
110 CommittedCutover,
112 RolledBack,
114 Failed,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum PendingEffectTreatment {
122 PreservePending,
124 InvalidateBlocked,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum CanonicalPublicationContinuity {
132 PreserveCanonicalTruth,
134 ReissueCanonicalTruth,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum RuntimeUpgradeExecutionConstraint {
142 PreserveBundleProfile,
144 MixedDeterminismAllowed,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
150pub struct RuntimeUpgradeCompatibility {
151 pub execution_constraint: RuntimeUpgradeExecutionConstraint,
153 pub ownership_continuity_required: bool,
155 pub pending_effect_treatment: PendingEffectTreatment,
157 pub canonical_publication_continuity: CanonicalPublicationContinuity,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
163pub struct RuntimeUpgradeArtifact {
164 pub upgrade_id: String,
166 pub phase: TransitionArtifactPhase,
168 pub previous_members: Vec<String>,
170 pub next_members: Vec<String>,
172 pub compatibility: RuntimeUpgradeCompatibility,
174 pub carried_publication_ids: Vec<String>,
176 pub invalidated_publication_ids: Vec<String>,
178 pub carried_obligation_ids: Vec<String>,
180 pub invalidated_obligation_ids: Vec<String>,
182 pub reason: Option<String>,
184}
185
186#[derive(Debug, Clone)]
187struct ResolvedPlacement {
188 node_key: String,
189 region: Option<String>,
190 uses_colocation: bool,
191 remote: bool,
192}
193
194fn normalize_placement_observations(
195 observations: &[PlacementObservation],
196) -> Result<Vec<PlacementObservation>, String> {
197 let mut normalized = observations.to_vec();
198 normalized.sort_by(|left, right| left.role.cmp(&right.role));
199
200 let mut seen_roles = BTreeSet::new();
201 for observation in &normalized {
202 if !seen_roles.insert(observation.role.clone()) {
203 return Err(format!(
204 "duplicate placement observation for role {}",
205 observation.role
206 ));
207 }
208 match observation.kind {
209 PlacementKind::Local => {
210 if observation.endpoint.is_some() {
211 return Err(format!(
212 "local role {} must not carry a remote endpoint",
213 observation.role
214 ));
215 }
216 if observation.colocated_with.is_some() {
217 return Err(format!(
218 "local role {} must not carry colocated_with metadata",
219 observation.role
220 ));
221 }
222 }
223 PlacementKind::Remote => {
224 if observation.endpoint.is_none() {
225 return Err(format!(
226 "remote role {} must carry an endpoint",
227 observation.role
228 ));
229 }
230 if observation.colocated_with.is_some() {
231 return Err(format!(
232 "remote role {} must not carry colocated_with metadata",
233 observation.role
234 ));
235 }
236 }
237 PlacementKind::Colocated => {
238 let Some(peer) = observation.colocated_with.as_ref() else {
239 return Err(format!(
240 "colocated role {} must name its colocated peer",
241 observation.role
242 ));
243 };
244 if peer == &observation.role {
245 return Err(format!(
246 "role {} may not be colocated with itself",
247 observation.role
248 ));
249 }
250 if observation.endpoint.is_some() {
251 return Err(format!(
252 "colocated role {} must not carry a direct endpoint",
253 observation.role
254 ));
255 }
256 }
257 }
258 }
259
260 Ok(normalized)
261}
262
263fn resolve_placement(
264 role: &str,
265 placements: &BTreeMap<String, PlacementObservation>,
266 visiting: &mut BTreeSet<String>,
267) -> Result<ResolvedPlacement, String> {
268 if !visiting.insert(role.to_string()) {
269 return Err(format!("cyclic colocated placement involving role {role}"));
270 }
271
272 let resolved = match placements.get(role) {
273 Some(PlacementObservation {
274 kind: PlacementKind::Local,
275 region,
276 ..
277 }) => Ok(ResolvedPlacement {
278 node_key: "local".to_string(),
279 region: region.clone(),
280 uses_colocation: false,
281 remote: false,
282 }),
283 Some(PlacementObservation {
284 kind: PlacementKind::Remote,
285 endpoint,
286 region,
287 ..
288 }) => Ok(ResolvedPlacement {
289 node_key: format!("remote:{}", endpoint.clone().unwrap_or_default()),
290 region: region.clone(),
291 uses_colocation: false,
292 remote: true,
293 }),
294 Some(PlacementObservation {
295 kind: PlacementKind::Colocated,
296 role,
297 colocated_with,
298 region,
299 ..
300 }) => {
301 let peer = colocated_with
302 .as_ref()
303 .expect("normalized colocated observation should name its peer");
304 let inherited = resolve_placement(peer, placements, visiting)?;
305 if let (Some(explicit), Some(inherited_region)) =
306 (region.as_ref(), inherited.region.as_ref())
307 {
308 if explicit != inherited_region {
309 return Err(format!(
310 "role {role} declares region {explicit} but colocated peer resolves to {inherited_region}"
311 ));
312 }
313 }
314 Ok(ResolvedPlacement {
315 node_key: inherited.node_key,
316 region: region.clone().or(inherited.region),
317 uses_colocation: true,
318 remote: inherited.remote,
319 })
320 }
321 None => Err(format!("placement observation is missing role {role}")),
322 };
323
324 visiting.remove(role);
325 resolved
326}
327
328pub fn canonicalize_placement_observations(
334 observations: &[PlacementObservation],
335) -> Result<Vec<PlacementObservation>, String> {
336 let normalized = normalize_placement_observations(observations)?;
337 let placements = normalized
338 .iter()
339 .cloned()
340 .map(|observation| (observation.role.clone(), observation))
341 .collect::<BTreeMap<_, _>>();
342 for role in placements.keys() {
343 resolve_placement(role, &placements, &mut BTreeSet::new())?;
344 }
345 Ok(normalized)
346}
347
348pub fn canonical_transport_boundaries(
354 observations: &[PlacementObservation],
355) -> Result<Vec<TransportBoundaryObservation>, String> {
356 let normalized = canonicalize_placement_observations(observations)?;
357 let placements = normalized
358 .iter()
359 .cloned()
360 .map(|observation| (observation.role.clone(), observation))
361 .collect::<BTreeMap<_, _>>();
362 let mut resolved = BTreeMap::new();
363 for role in placements.keys() {
364 resolved.insert(
365 role.clone(),
366 resolve_placement(role, &placements, &mut BTreeSet::new())?,
367 );
368 }
369
370 let roles = normalized
371 .iter()
372 .map(|observation| observation.role.clone())
373 .collect::<Vec<_>>();
374 let mut boundaries = Vec::new();
375 for (index, left_role) in roles.iter().enumerate() {
376 for right_role in roles.iter().skip(index + 1) {
377 let left_resolved = resolved
378 .get(left_role)
379 .expect("resolved placements should exist for every role");
380 let right_resolved = resolved
381 .get(right_role)
382 .expect("resolved placements should exist for every role");
383 let boundary = if left_resolved.remote || right_resolved.remote {
384 TransportBoundaryKind::Network
385 } else if left_resolved.uses_colocation || right_resolved.uses_colocation {
386 TransportBoundaryKind::SharedMemory
387 } else {
388 TransportBoundaryKind::InProcess
389 };
390 let cross_region = match (&left_resolved.region, &right_resolved.region) {
391 (Some(left), Some(right)) => left != right,
392 _ => false,
393 };
394 boundaries.push(TransportBoundaryObservation {
395 from_role: left_role.clone(),
396 to_role: right_role.clone(),
397 boundary,
398 cross_region,
399 });
400 }
401 }
402 Ok(boundaries)
403}
404
405#[cfg(test)]
406mod tests {
407 use super::{canonical_transport_boundaries, PlacementObservation, TransportBoundaryKind};
408
409 #[test]
410 fn remote_and_colocated_boundaries_are_canonical() {
411 let boundaries = canonical_transport_boundaries(&[
412 PlacementObservation::local("Alice").with_region("eu_central_1"),
413 PlacementObservation::remote("Bob", "127.0.0.1:19801").with_region("eu_west_1"),
414 PlacementObservation::colocated("Carol", "Alice").with_region("eu_central_1"),
415 ])
416 .expect("valid placement observations");
417
418 assert_eq!(
419 boundaries,
420 vec![
421 super::TransportBoundaryObservation {
422 from_role: "Alice".to_string(),
423 to_role: "Bob".to_string(),
424 boundary: TransportBoundaryKind::Network,
425 cross_region: true,
426 },
427 super::TransportBoundaryObservation {
428 from_role: "Alice".to_string(),
429 to_role: "Carol".to_string(),
430 boundary: TransportBoundaryKind::SharedMemory,
431 cross_region: false,
432 },
433 super::TransportBoundaryObservation {
434 from_role: "Bob".to_string(),
435 to_role: "Carol".to_string(),
436 boundary: TransportBoundaryKind::Network,
437 cross_region: true,
438 },
439 ]
440 );
441 }
442
443 #[test]
444 fn conflicting_colocated_regions_reject() {
445 let error = canonical_transport_boundaries(&[
446 PlacementObservation::local("Alice").with_region("eu_central_1"),
447 PlacementObservation::colocated("Bob", "Alice").with_region("us_east_1"),
448 ])
449 .expect_err("conflicting colocated regions must reject");
450
451 assert!(
452 error.contains("declares region"),
453 "expected explicit colocated-region conflict, got {error}"
454 );
455 }
456}