use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use thiserror::Error;
use crate::engine::inline_stash;
use crate::environment::Environment;
use crate::error::SpecError;
use crate::ids::{DeploymentId, RevisionId};
use crate::remote::IdempotencyKey;
use crate::revision::RevisionLifecycle;
use crate::traffic_split::{TrafficSplit, TrafficSplitEntry};
use crate::version::SchemaVersion;
use greentic_types::EnvId;
#[derive(Debug, PartialEq, Eq, Error)]
pub enum TrafficSplitError {
#[error("deployment `{deployment_id}` not found in env `{env_id}`")]
DeploymentNotFound {
env_id: EnvId,
deployment_id: DeploymentId,
},
#[error("revision `{revision_id}` not found in env `{env_id}`")]
RevisionNotFound {
env_id: EnvId,
revision_id: RevisionId,
},
#[error("revision `{revision_id}` belongs to deployment `{actual}`, not `{expected}`")]
WrongDeployment {
revision_id: RevisionId,
actual: DeploymentId,
expected: DeploymentId,
},
#[error(
"idempotency key `{key}` already used for deployment `{deployment_id}` with different entries"
)]
IdempotencyKeyReused {
key: String,
deployment_id: DeploymentId,
},
#[error("revision `{revision_id}` not found in env `{env_id}`")]
AdmissionRevisionMissing {
env_id: EnvId,
revision_id: RevisionId,
},
#[error(
"revision `{revision_id}` is `{lifecycle:?}`; only `Ready` revisions may receive traffic"
)]
NotReady {
revision_id: RevisionId,
lifecycle: RevisionLifecycle,
},
#[error("snapshot prior split: {detail}")]
SnapshotEncode { detail: String },
#[error("no traffic split for deployment `{deployment_id}` in env `{env_id}`")]
NoSplit {
env_id: EnvId,
deployment_id: DeploymentId,
},
#[error("traffic split for `{deployment_id}` has no prior version to roll back to")]
NoPreviousSnapshot { deployment_id: DeploymentId },
#[error("previous split payload `{}` missing", prev_ref.display())]
SnapshotMissing { prev_ref: PathBuf },
#[error("deserialise previous split: {detail}")]
SnapshotDecode { detail: String },
#[error(transparent)]
Invalid(#[from] SpecError),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetTrafficSplitPayload {
pub deployment_id: DeploymentId,
pub entries: Vec<TrafficSplitEntry>,
pub updated_by: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authorization_ref: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RollbackTrafficSplitPayload {
pub deployment_id: DeploymentId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApplyTrafficSplitOutcome {
pub split: TrafficSplit,
pub previous_generation: Option<u64>,
pub new_generation: Option<u64>,
pub environment: Environment,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RollbackTrafficSplitOutcome {
pub restored: TrafficSplit,
pub previous_generation: u64,
pub new_generation: u64,
pub environment: Environment,
}
#[derive(Debug, Clone)]
pub struct TrafficSplitTransition {
pub split: TrafficSplit,
pub previous_generation: Option<u64>,
pub new_generation: Option<u64>,
}
impl TrafficSplitTransition {
pub fn mutated(&self) -> bool {
self.new_generation.is_some()
}
}
#[derive(Debug, Clone)]
pub struct TrafficRollbackTransition {
pub restored: TrafficSplit,
pub previous_generation: u64,
pub new_generation: u64,
}
pub fn set_traffic_split(
env: &mut Environment,
payload: SetTrafficSplitPayload,
idempotency_key: &IdempotencyKey,
now: DateTime<Utc>,
) -> Result<TrafficSplitTransition, TrafficSplitError> {
let SetTrafficSplitPayload {
deployment_id,
entries,
updated_by,
authorization_ref,
} = payload;
let env_id = env.environment_id.clone();
let deployment = env
.bundles
.iter()
.find(|b| b.deployment_id == deployment_id)
.ok_or_else(|| TrafficSplitError::DeploymentNotFound {
env_id: env_id.clone(),
deployment_id,
})?;
let bundle_id = deployment.bundle_id.clone();
for entry in &entries {
let rev = env
.revisions
.iter()
.find(|r| r.revision_id == entry.revision_id)
.ok_or_else(|| TrafficSplitError::RevisionNotFound {
env_id: env_id.clone(),
revision_id: entry.revision_id,
})?;
if rev.deployment_id != deployment_id {
return Err(TrafficSplitError::WrongDeployment {
revision_id: entry.revision_id,
actual: rev.deployment_id,
expected: deployment_id,
});
}
}
let prev_split_idx = env
.traffic_splits
.iter()
.position(|s| s.deployment_id == deployment_id);
if let Some(idx) = prev_split_idx {
let prev = &env.traffic_splits[idx];
if prev.idempotency_key == idempotency_key.as_str() {
if entries_match(&prev.entries, &entries) {
return Ok(TrafficSplitTransition {
split: prev.clone(),
previous_generation: None,
new_generation: None,
});
}
return Err(TrafficSplitError::IdempotencyKeyReused {
key: idempotency_key.as_str().to_string(),
deployment_id,
});
}
}
assert_entries_all_ready(env, &entries)?;
let (generation, previous_split_ref, prev_gen) = match prev_split_idx {
Some(idx) => {
let mut stash_copy = env.traffic_splits[idx].clone();
let prev_generation = stash_copy.generation;
stash_copy.previous_split_ref = None;
let snapshot = serde_json::to_value(&stash_copy).map_err(|e| {
TrafficSplitError::SnapshotEncode {
detail: e.to_string(),
}
})?;
(
prev_generation + 1,
Some(inline_stash::stash_inline(snapshot)),
Some(prev_generation),
)
}
None => (0, None, None),
};
let split = TrafficSplit {
schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
env_id,
deployment_id,
bundle_id,
generation,
entries,
updated_at: now,
updated_by,
idempotency_key: idempotency_key.as_str().to_string(),
authorization_ref: authorization_ref
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("auth.json")),
previous_split_ref,
};
split.validate()?;
match prev_split_idx {
Some(idx) => env.traffic_splits[idx] = split.clone(),
None => env.traffic_splits.push(split.clone()),
}
Ok(TrafficSplitTransition {
split,
previous_generation: prev_gen,
new_generation: Some(generation),
})
}
pub fn rollback_traffic_split(
env: &mut Environment,
deployment_id: DeploymentId,
now: DateTime<Utc>,
) -> Result<TrafficRollbackTransition, TrafficSplitError> {
let idx = env
.traffic_splits
.iter()
.position(|s| s.deployment_id == deployment_id)
.ok_or_else(|| TrafficSplitError::NoSplit {
env_id: env.environment_id.clone(),
deployment_id,
})?;
let prev_split_generation = env.traffic_splits[idx].generation;
let prev_ref = env.traffic_splits[idx]
.previous_split_ref
.clone()
.ok_or(TrafficSplitError::NoPreviousSnapshot { deployment_id })?;
let prev_value = inline_stash::load_inline(&prev_ref)
.ok_or(TrafficSplitError::SnapshotMissing { prev_ref })?;
let mut restored: TrafficSplit =
serde_json::from_value(prev_value).map_err(|e| TrafficSplitError::SnapshotDecode {
detail: e.to_string(),
})?;
let new_generation = prev_split_generation + 1;
restored.generation = new_generation;
restored.previous_split_ref = None;
restored.updated_at = now;
restored.idempotency_key = format!("rollback-{}", env.traffic_splits[idx].idempotency_key);
restored.validate()?;
assert_entries_all_ready(env, &restored.entries)?;
env.traffic_splits[idx] = restored.clone();
Ok(TrafficRollbackTransition {
restored,
previous_generation: prev_split_generation,
new_generation,
})
}
pub fn assert_entries_all_ready(
env: &Environment,
entries: &[TrafficSplitEntry],
) -> Result<(), TrafficSplitError> {
for entry in entries {
let rev = env
.revisions
.iter()
.find(|r| r.revision_id == entry.revision_id)
.ok_or_else(|| TrafficSplitError::AdmissionRevisionMissing {
env_id: env.environment_id.clone(),
revision_id: entry.revision_id,
})?;
if rev.lifecycle != RevisionLifecycle::Ready {
return Err(TrafficSplitError::NotReady {
revision_id: entry.revision_id,
lifecycle: rev.lifecycle,
});
}
}
Ok(())
}
pub fn entries_match(a: &[TrafficSplitEntry], b: &[TrafficSplitEntry]) -> bool {
if a.len() != b.len() {
return false;
}
let mut a_sorted: Vec<(&RevisionId, u32)> =
a.iter().map(|e| (&e.revision_id, e.weight_bps)).collect();
let mut b_sorted: Vec<(&RevisionId, u32)> =
b.iter().map(|e| (&e.revision_id, e.weight_bps)).collect();
a_sorted.sort_by_key(|(r, _)| r.to_string());
b_sorted.sort_by_key(|(r, _)| r.to_string());
a_sorted == b_sorted
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bundle_deployment::{
BundleDeployment, BundleDeploymentStatus, RevenueShareEntry, RouteBinding, TenantSelector,
};
use crate::engine::{StageRevisionPayload, stage_revision};
use crate::ids::{BundleId, CustomerId, PackId, PartyId};
use crate::revision::PackListEntry;
use crate::version::SemVer;
use chrono::TimeZone;
use serde_json::json;
use std::collections::BTreeMap;
fn env_id() -> EnvId {
EnvId::try_from("local").unwrap()
}
fn fixed_now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 6, 12, 12, 0, 0).unwrap()
}
fn idem(raw: &str) -> IdempotencyKey {
IdempotencyKey::new(raw).expect("valid idempotency key")
}
fn deployment(deployment_id: DeploymentId) -> BundleDeployment {
BundleDeployment {
schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
deployment_id,
env_id: env_id(),
bundle_id: BundleId::new("fast2flow"),
customer_id: CustomerId::new("local-dev"),
status: BundleDeploymentStatus::Active,
current_revisions: Vec::new(),
route_binding: RouteBinding {
hosts: vec!["fast2flow.local".to_string()],
path_prefixes: Vec::new(),
tenant_selector: TenantSelector {
tenant: "default".to_string(),
team: "default".to_string(),
},
},
revenue_share: vec![RevenueShareEntry {
party_id: PartyId::new("greentic"),
basis_points: 10_000,
}],
revenue_policy_ref: PathBuf::from("revenue.json"),
usage: None,
created_at: fixed_now(),
authorization_ref: PathBuf::from("auth.json"),
config_overrides: BTreeMap::new(),
}
}
fn stage_payload(deployment_id: DeploymentId) -> StageRevisionPayload {
StageRevisionPayload {
revision_id: RevisionId::new(),
deployment_id,
bundle_digest: "sha256:00".to_string(),
bundle_source_uri: None,
pack_list: vec![PackListEntry {
pack_id: PackId::new("greentic.test.pack"),
version: SemVer::new(1, 0, 0),
digest: "sha256:00".to_string(),
source_uri: None,
}],
pack_list_lock_ref: PathBuf::from("pack-list.lock"),
pack_config_refs: Vec::new(),
config_digest: "sha256:00".to_string(),
signature_sidecar_ref: PathBuf::from("rev.sig"),
drain_seconds: 30,
}
}
fn env_with_ready_revisions(
deployment_id: DeploymentId,
) -> (Environment, RevisionId, RevisionId) {
let mut env = super::super::fresh_environment(
&env_id(),
"local".to_string(),
crate::environment::EnvironmentHostConfig {
env_id: env_id(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
gui_enabled: None,
},
Default::default(),
Default::default(),
Default::default(),
);
env.bundles.push(deployment(deployment_id));
let r1 = stage_revision(&mut env, stage_payload(deployment_id), fixed_now())
.unwrap()
.revision_id;
let r2 = stage_revision(&mut env, stage_payload(deployment_id), fixed_now())
.unwrap()
.revision_id;
for rev in &mut env.revisions {
rev.lifecycle = RevisionLifecycle::Ready;
}
(env, r1, r2)
}
fn set_payload(
deployment_id: DeploymentId,
entries: Vec<TrafficSplitEntry>,
) -> SetTrafficSplitPayload {
SetTrafficSplitPayload {
deployment_id,
entries,
updated_by: "operator@local".to_string(),
authorization_ref: None,
}
}
fn full_weight(revision_id: RevisionId) -> Vec<TrafficSplitEntry> {
vec![TrafficSplitEntry {
revision_id,
weight_bps: 10_000,
}]
}
#[test]
fn set_first_split_starts_at_generation_zero_without_stash() {
let deployment_id = DeploymentId::new();
let (mut env, r1, _) = env_with_ready_revisions(deployment_id);
let t = set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(r1)),
&idem("k1"),
fixed_now(),
)
.unwrap();
assert!(t.mutated());
assert_eq!(t.split.generation, 0);
assert_eq!(t.previous_generation, None);
assert_eq!(t.new_generation, Some(0));
assert_eq!(t.split.previous_split_ref, None);
assert_eq!(t.split.idempotency_key, "k1");
assert_eq!(
t.split.authorization_ref,
PathBuf::from("auth.json"),
"absent authorization_ref defaults"
);
assert_eq!(env.traffic_splits.len(), 1);
}
#[test]
fn set_second_split_advances_generation_and_stashes_previous() {
let deployment_id = DeploymentId::new();
let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(r1)),
&idem("k1"),
fixed_now(),
)
.unwrap();
let t = set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(r2)),
&idem("k2"),
fixed_now(),
)
.unwrap();
assert_eq!(t.previous_generation, Some(0));
assert_eq!(t.new_generation, Some(1));
let stash = t.split.previous_split_ref.as_ref().expect("stash present");
let prev: TrafficSplit =
serde_json::from_value(inline_stash::load_inline(stash).expect("stash decodes"))
.expect("stash is a TrafficSplit");
assert_eq!(prev.generation, 0);
assert_eq!(prev.entries, full_weight(r1));
assert_eq!(env.traffic_splits.len(), 1, "replace, not append");
}
#[test]
fn set_same_key_same_entries_is_a_no_op_replay() {
let deployment_id = DeploymentId::new();
let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
let entries = vec![
TrafficSplitEntry {
revision_id: r1,
weight_bps: 4_000,
},
TrafficSplitEntry {
revision_id: r2,
weight_bps: 6_000,
},
];
set_traffic_split(
&mut env,
set_payload(deployment_id, entries.clone()),
&idem("k1"),
fixed_now(),
)
.unwrap();
let snapshot = env.clone();
let permuted: Vec<TrafficSplitEntry> = entries.into_iter().rev().collect();
let t = set_traffic_split(
&mut env,
set_payload(deployment_id, permuted),
&idem("k1"),
fixed_now(),
)
.unwrap();
assert!(!t.mutated());
assert_eq!(t.previous_generation, None);
assert_eq!(t.new_generation, None);
assert_eq!(t.split.generation, 0, "replay returns the live split");
assert_eq!(env, snapshot, "no-op replay must not touch the env");
}
#[test]
fn set_same_key_different_entries_is_a_key_reuse_conflict() {
let deployment_id = DeploymentId::new();
let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(r1)),
&idem("k1"),
fixed_now(),
)
.unwrap();
let err = set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(r2)),
&idem("k1"),
fixed_now(),
)
.unwrap_err();
assert!(
matches!(&err, TrafficSplitError::IdempotencyKeyReused { key, .. } if key == "k1"),
"unexpected error: {err:?}"
);
}
#[test]
fn set_rejects_unknown_deployment_and_unknown_revision() {
let deployment_id = DeploymentId::new();
let (mut env, r1, _) = env_with_ready_revisions(deployment_id);
let err = set_traffic_split(
&mut env,
set_payload(DeploymentId::new(), full_weight(r1)),
&idem("k1"),
fixed_now(),
)
.unwrap_err();
assert!(matches!(err, TrafficSplitError::DeploymentNotFound { .. }));
let err = set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(RevisionId::new())),
&idem("k1"),
fixed_now(),
)
.unwrap_err();
assert!(matches!(err, TrafficSplitError::RevisionNotFound { .. }));
assert!(env.traffic_splits.is_empty());
}
#[test]
fn set_rejects_revision_owned_by_another_deployment() {
let deployment_id = DeploymentId::new();
let (mut env, _, _) = env_with_ready_revisions(deployment_id);
let other_deployment = DeploymentId::new();
env.bundles.push(deployment(other_deployment));
let foreign = stage_revision(&mut env, stage_payload(other_deployment), fixed_now())
.unwrap()
.revision_id;
env.revisions.last_mut().unwrap().lifecycle = RevisionLifecycle::Ready;
let err = set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(foreign)),
&idem("k1"),
fixed_now(),
)
.unwrap_err();
assert!(
matches!(
&err,
TrafficSplitError::WrongDeployment { revision_id, actual, expected }
if *revision_id == foreign
&& *actual == other_deployment
&& *expected == deployment_id
),
"unexpected error: {err:?}"
);
}
#[test]
fn set_rejects_non_ready_revision_per_admission_gate() {
let deployment_id = DeploymentId::new();
let (mut env, r1, _) = env_with_ready_revisions(deployment_id);
env.revisions[0].lifecycle = RevisionLifecycle::Staged;
let err = set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(r1)),
&idem("k1"),
fixed_now(),
)
.unwrap_err();
assert!(
matches!(
&err,
TrafficSplitError::NotReady { lifecycle, .. }
if *lifecycle == RevisionLifecycle::Staged
),
"unexpected error: {err:?}"
);
}
#[test]
fn set_rejects_weights_not_summing_to_ten_thousand() {
let deployment_id = DeploymentId::new();
let (mut env, r1, _) = env_with_ready_revisions(deployment_id);
let err = set_traffic_split(
&mut env,
set_payload(
deployment_id,
vec![TrafficSplitEntry {
revision_id: r1,
weight_bps: 9_999,
}],
),
&idem("k1"),
fixed_now(),
)
.unwrap_err();
assert!(
matches!(err, TrafficSplitError::Invalid(_)),
"unexpected error: {err:?}"
);
assert!(env.traffic_splits.is_empty(), "invalid split not applied");
}
#[test]
fn rollback_restores_previous_entries_and_advances_generation() {
let deployment_id = DeploymentId::new();
let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(r1)),
&idem("k1"),
fixed_now(),
)
.unwrap();
set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(r2)),
&idem("k2"),
fixed_now(),
)
.unwrap();
let t = rollback_traffic_split(&mut env, deployment_id, fixed_now()).unwrap();
assert_eq!(t.previous_generation, 1);
assert_eq!(t.new_generation, 2, "rollback advances, never rewinds");
assert_eq!(t.restored.entries, full_weight(r1));
assert_eq!(t.restored.idempotency_key, "rollback-k2");
assert_eq!(
t.restored.previous_split_ref, None,
"one-step rollback only"
);
assert_eq!(env.traffic_splits[0], t.restored);
let err = rollback_traffic_split(&mut env, deployment_id, fixed_now()).unwrap_err();
assert!(matches!(err, TrafficSplitError::NoPreviousSnapshot { .. }));
}
#[test]
fn rollback_without_split_is_no_split() {
let deployment_id = DeploymentId::new();
let (mut env, _, _) = env_with_ready_revisions(deployment_id);
let err = rollback_traffic_split(&mut env, deployment_id, fixed_now()).unwrap_err();
assert!(matches!(err, TrafficSplitError::NoSplit { .. }));
}
#[test]
fn rollback_re_checks_admission_on_restored_entries() {
let deployment_id = DeploymentId::new();
let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(r1)),
&idem("k1"),
fixed_now(),
)
.unwrap();
set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(r2)),
&idem("k2"),
fixed_now(),
)
.unwrap();
env.revisions[0].lifecycle = RevisionLifecycle::Archived;
let snapshot = env.clone();
let err = rollback_traffic_split(&mut env, deployment_id, fixed_now()).unwrap_err();
assert!(
matches!(&err, TrafficSplitError::NotReady { revision_id, .. } if *revision_id == r1),
"unexpected error: {err:?}"
);
assert_eq!(env, snapshot, "failed rollback must not mutate the env");
}
#[test]
fn entries_match_is_order_insensitive_and_weight_sensitive() {
let (r1, r2) = (RevisionId::new(), RevisionId::new());
let a = vec![
TrafficSplitEntry {
revision_id: r1,
weight_bps: 4_000,
},
TrafficSplitEntry {
revision_id: r2,
weight_bps: 6_000,
},
];
let permuted: Vec<TrafficSplitEntry> = a.iter().rev().cloned().collect();
assert!(entries_match(&a, &permuted));
let reweighted = vec![
TrafficSplitEntry {
revision_id: r1,
weight_bps: 5_000,
},
TrafficSplitEntry {
revision_id: r2,
weight_bps: 5_000,
},
];
assert!(!entries_match(&a, &reweighted));
assert!(!entries_match(&a, &a[..1]));
}
#[test]
fn set_payload_wire_format_is_pinned() {
let deployment_id = DeploymentId::new();
let revision_id = RevisionId::new();
let payload = SetTrafficSplitPayload {
deployment_id,
entries: vec![TrafficSplitEntry {
revision_id,
weight_bps: 10_000,
}],
updated_by: "operator@local".to_string(),
authorization_ref: None,
};
assert_eq!(
serde_json::to_value(&payload).unwrap(),
json!({
"deployment_id": deployment_id.to_string(),
"entries": [{"revision_id": revision_id.to_string(), "weight_bps": 10_000}],
"updated_by": "operator@local",
}),
"authorization_ref must be ABSENT when None (client encoding)"
);
let rollback = RollbackTrafficSplitPayload { deployment_id };
assert_eq!(
serde_json::to_value(&rollback).unwrap(),
json!({"deployment_id": deployment_id.to_string()})
);
}
#[test]
fn apply_outcome_round_trips_with_environment_snapshot() {
let deployment_id = DeploymentId::new();
let (mut env, r1, _) = env_with_ready_revisions(deployment_id);
let t = set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(r1)),
&idem("k1"),
fixed_now(),
)
.unwrap();
let outcome = ApplyTrafficSplitOutcome {
split: t.split,
previous_generation: t.previous_generation,
new_generation: t.new_generation,
environment: env,
};
let value = serde_json::to_value(&outcome).unwrap();
assert_eq!(value["new_generation"], 0);
assert_eq!(value["previous_generation"], serde_json::Value::Null);
assert_eq!(value["environment"]["environment_id"], "local");
let back: ApplyTrafficSplitOutcome = serde_json::from_value(value).unwrap();
assert_eq!(back.split, outcome.split);
assert_eq!(back.environment, outcome.environment);
}
#[test]
fn stash_is_bounded_to_one_level_across_multiple_sets() {
let deployment_id = DeploymentId::new();
let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(r1)),
&idem("k1"),
fixed_now(),
)
.unwrap();
let t2 = set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(r2)),
&idem("k2"),
fixed_now(),
)
.unwrap();
let t3 = set_traffic_split(
&mut env,
set_payload(deployment_id, full_weight(r1)),
&idem("k3"),
fixed_now(),
)
.unwrap();
let stash3 = t3
.split
.previous_split_ref
.as_ref()
.expect("stash present after set #3");
let prev3: TrafficSplit =
serde_json::from_value(inline_stash::load_inline(stash3).expect("stash decodes"))
.expect("stash is a TrafficSplit");
assert_eq!(
prev3.previous_split_ref, None,
"stash must be one level deep"
);
assert_eq!(prev3.generation, 1);
assert_eq!(prev3.entries, full_weight(r2));
let stash2 = t2
.split
.previous_split_ref
.as_ref()
.expect("stash present after set #2");
let prev2: TrafficSplit =
serde_json::from_value(inline_stash::load_inline(stash2).expect("stash decodes"))
.expect("stash is a TrafficSplit");
assert_eq!(
prev2.previous_split_ref, None,
"stash must be one level deep"
);
let len3 = stash3.as_os_str().len();
let len2 = stash2.as_os_str().len();
assert!(
len3 <= len2 + 64,
"stash tokens must not grow unboundedly: len(#3)={len3} vs len(#2)={len2}",
);
}
}