crates/monetize-server/tests/signup_start_latency.rs
//! **`Signup.Start` is the moment between "Continue" and Stripe's card form**,
//! and on the real test2 (UpCloud, live Stripe, 2026-09-30) it took 25.5 s, then
//! 33 s, then did not answer at all for more than 100 s. The bench — no grow
//! set, a fake Stripe — answered in 34-75 ms, so nothing on the bench could see it.
//!
//! MEASURED on test2's front, `monetize-server: signup capacity: gunnar: the
//! sale was allowed …` (the oversell gate's `admit`) is the LAST line before the
//! silence, and main's log shows monetize's `Admin.Capacity` answered 50 ms into
//! the request. After that line `admit` asks `oversell::past_the_wallet` — a
//! READING that refuses nothing — and that called `grow::affordable_bytes`
//! inline: a survey and a bisect of `price_grow`, about a dozen ops on the
//! UpCloud cloud GUEST, each op a fresh guest (so a fresh `GET /1.3/price` on
//! top of `GET /server/{uuid}` and one `GET /storage/{uuid}` per member). ~48
//! sequential UpCloud API calls on the customer's path, and every op of the
//! cloud guest queues behind the guest's one `serial` lock — which a growth
//! holds for up to 15 minutes.
//!
//! This file is that estate with latency: the cloud answers each op in
//! [`PER_OP`] (four UpCloud calls at 150 ms), the vendor mints in [`PER_MINT`]
//! (a request plus a TLS handshake at 150 ms each), and the cloud has one lock
//! every op takes, as the guest does.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use monetize::testing::{GrowScript, MockCloud, MockProduct};
use monetize_cloud::cap::{Ceiling, SpendCap};
use monetize_cloud::{grow, CloudError, Ensured, Inventory, Price, Resource, ResourceId, ResourceStatus, Room, Zone};
use monetize_pay::signup::{SignupCheckout, SignupOrder};
use monetize_pay::{CallbackEvent, ChargeHandle, ChargeRequest, ChargeStatus, Currency, Market, Money, PayError, RawCallback, Settlement};
use monetize_product::{Growth, Product, Servable};
use monetize_proto::signup_client::SignupClient;
use monetize_proto::{self as pb};
use monetize_server::config::{GrowSetConfig, SignupPlan};
use monetize_server::grow::GrowRuns;
use monetize_server::registry::Registry;
use monetize_server::state::AppState;
use monetize_server::testing::{CallbackPay, TestServer};
use tonic::metadata::MetadataValue;
use tonic::transport::Channel;
use tonic::Request;
const BEARER: &str = "test-bearer-token-of-sufficient-length";
const PLAN: &str = "gunnar/10gb-1m";
const GIB: u64 = 1 << 30;
/// One cloud-guest op on the real account: `GET /1.3/server/{uuid}`, two
/// `GET /1.3/storage/{uuid}`, and `GET /1.3/price` (the guest's price book
/// lives one op) — four calls at a realistic 150 ms each.
const PER_OP: Duration = Duration::from_millis(600);
/// Stripe's `POST /v1/checkout/sessions`: one request and one TLS handshake.
const PER_MINT: Duration = Duration::from_millis(300);
/// test2's `[clouds.upcloud] cap`: monthly 50.00 EUR.
const MONTHLY_MINOR: u64 = 5_000;
/// **The cloud guest, as far as latency goes**: every op takes [`PER_OP`] and
/// holds the one lock `GuestCloud::call_within` holds (`serial`).
struct SlowCloud {
inner: Arc<MockCloud>,
serial: Arc<Mutex<()>>,
ops: Arc<AtomicU64>,
}
impl SlowCloud {
fn op<T>(&self, f: impl FnOnce() -> T) -> T {
let _one = self.serial.lock().unwrap_or_else(|p| p.into_inner());
self.ops.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(PER_OP);
f()
}
}
#[allow(dead_code)]
impl SlowCloud {
pub fn provider(&self) -> &'static str {
self.inner.provider()
}
pub fn zones(&self) -> &[Zone] {
&[]
}
pub fn price_resource(&self, r: &Resource) -> Result<Price, CloudError> {
self.op(|| monetize_cloud::Cloud::price(&self.inner, &monetize_cloud::cloud::Priced::Resource(r)))
}
pub fn ensure(&self, reference: &str, r: &Resource) -> Result<Ensured, CloudError> {
self.op(|| self.inner.ensure(reference, r))
}
pub fn ensure_with(&self, reference: &str, r: &Resource, room: Room) -> Result<Ensured, CloudError> {
self.op(|| self.inner.ensure_with(reference, r, room))
}
pub fn destroy_id(&self, id: &ResourceId) -> Result<(), CloudError> {
self.op(|| monetize_cloud::ask::destroy_one(&self.inner, id))
}
pub fn status(&self, id: &ResourceId) -> Result<ResourceStatus, CloudError> {
self.op(|| self.inner.status(id))
}
pub fn list_owned(&self) -> Result<Inventory, CloudError> {
self.op(|| monetize_cloud::ask::account(&self.inner))
}
/// PURE in the guest too (the block's `allow_grow`): no op, no lock.
pub fn grow_gate(&self) -> Result<(), CloudError> {
self.inner.grow_gate()
}
pub fn price_grow(&self, set: &grow::DataSet) -> Result<Price, CloudError> {
self.op(|| self.inner.price_grow(set))
}
pub fn survey_set(&self, set: &grow::DataSet) -> Result<grow::SetSurvey, CloudError> {
self.op(|| self.inner.survey_set(set))
}
pub fn grow_set(&self, set: &grow::DataSet, journal: &grow::Journal) -> Result<grow::GrowOutcome, CloudError> {
self.op(|| self.inner.grow_set(set, journal))
}
pub fn resume_grow(&self, set: &grow::DataSet, journal: &grow::Journal) -> Result<grow::GrowOutcome, CloudError> {
self.op(|| self.inner.resume_grow(set, journal))
}
}
#[allow(unused_variables, unreachable_code)]
impl monetize_cloud::cloud::Cloud for SlowCloud {
fn manifest(&self) -> monetize_cloud::cloud::CloudManifest {
monetize_cloud::cloud::CloudManifest { provider: self.provider().to_string(), zones: self.zones().to_vec(), settings: Vec::new(), grow_refused: self.grow_gate().err().map(|e| e.to_string()) }
}
fn catalog(&self, zone: &monetize_cloud::Zone) -> Result<monetize_cloud::cloud::Catalog, monetize_cloud::CloudError> {
Err(monetize_cloud::lay::unsupported(self.provider()))
}
fn price(&self, what: &monetize_cloud::cloud::Priced<'_>) -> Result<monetize_cloud::Price, monetize_cloud::CloudError> {
match what {
monetize_cloud::cloud::Priced::Resource(r) => self.price_resource(r),
monetize_cloud::cloud::Priced::Plan(p) => monetize_cloud::price_estate_by_rows(p, |r| self.price_resource(r), None),
monetize_cloud::cloud::Priced::Grow(set) => self.price_grow(set),
}
}
fn lay(&self, plan: &monetize_cloud::lay::EstatePlan, journal: &monetize_cloud::grow::Journal) -> Result<monetize_cloud::lay::LaidEstate, monetize_cloud::CloudError> {
Err(monetize_cloud::lay::unsupported(self.provider()))
}
fn change(&self, change: &monetize_cloud::cloud::Change, journal: &monetize_cloud::grow::Journal) -> Result<monetize_cloud::cloud::Changed, monetize_cloud::CloudError> {
Ok(match change {
monetize_cloud::cloud::Change::Plan { server, plan, delta } => return Err(monetize_cloud::lay::unsupported_verb(self.provider(), "change_plan")),
monetize_cloud::cloud::Change::Grow { set, estate } => monetize_cloud::cloud::Changed::Grown(self.grow_set(set, journal)?),
monetize_cloud::cloud::Change::Settle { set } => monetize_cloud::cloud::Changed::Grown(self.resume_grow(set, journal)?),
monetize_cloud::cloud::Change::ForgetMedia { laid } => monetize_cloud::cloud::Changed::Forgotten(Vec::new()),
monetize_cloud::cloud::Change::Ensure { reference, resource, room } => monetize_cloud::cloud::Changed::Ensured(match room { Some(r) => self.ensure_with(reference, resource, *r)?, None => self.ensure(reference, resource)? }),
monetize_cloud::cloud::Change::Retire { reference, machine } => monetize_cloud::cloud::Changed::Retired(monetize_cloud::cloud::retire_by_rows(self, reference, machine)?),
monetize_cloud::cloud::Change::LabelAdopted { .. } => return Err(monetize_cloud::lay::unsupported_verb(self.provider(), "label_adopted")),
})
}
fn boot(&self, boot: &monetize_cloud::cloud::Boot, journal: &monetize_cloud::grow::Journal) -> Result<monetize_cloud::cloud::Booted, monetize_cloud::CloudError> {
Ok(match boot {
monetize_cloud::cloud::Boot::Keep(k) => return Err(monetize_cloud::lay::unsupported_verb(self.provider(), "keep_boot")),
monetize_cloud::cloud::Boot::Power { server, on } => return Err(monetize_cloud::lay::unsupported_verb(self.provider(), "set_power")),
monetize_cloud::cloud::Boot::Restore(r) => return Err(monetize_cloud::lay::unsupported_verb(self.provider(), "restore_from_backup")),
})
}
fn firewall(&self, laid: &monetize_cloud::lay::LaidEstate) -> Result<(), monetize_cloud::CloudError> {
Err(monetize_cloud::lay::unsupported_verb(self.provider(), "set_estate_firewall"))
}
fn destroy(&self, what: &monetize_cloud::cloud::Give<'_>) -> Result<monetize_cloud::Reclaimed, monetize_cloud::CloudError> {
match what {
monetize_cloud::cloud::Give::References(refs) => Ok(monetize_cloud::release_for_by_rows(self.list_owned()?, refs, |id| self.destroy_id(id))),
monetize_cloud::cloud::Give::Resource(id) => {
self.destroy_id(id)?;
Ok(monetize_cloud::Reclaimed { destroyed: vec![(*id).clone()], ..monetize_cloud::Reclaimed::default() })
}
}
}
fn inventory(&self, what: &monetize_cloud::cloud::Listing<'_>) -> Result<monetize_cloud::cloud::Listed, monetize_cloud::CloudError> {
Ok(match what {
monetize_cloud::cloud::Listing::Account => monetize_cloud::cloud::Listed::Account(self.list_owned()?),
monetize_cloud::cloud::Listing::Set(set) => monetize_cloud::cloud::Listed::Set(self.survey_set(set)?),
monetize_cloud::cloud::Listing::Status(id) => monetize_cloud::cloud::Listed::Status(self.status(id)?),
monetize_cloud::cloud::Listing::Under(refs) => monetize_cloud::cloud::Listed::Under(monetize_cloud::cloud::under(self.list_owned()?, refs)),
monetize_cloud::cloud::Listing::Room { delta, room } => monetize_cloud::cloud::Listed::Room(monetize_cloud::cloud::RoomLeft { serve: Ok(()), left_bytes: None, ceilings: Default::default() }),
monetize_cloud::cloud::Listing::Admits(_plan) => monetize_cloud::cloud::Listed::Admitted,
})
}
}
/// **Stripe, with a round trip.** Everything but the mint is the plain double.
struct SlowVendor {
inner: CallbackPay,
mint: Duration,
}
#[allow(dead_code)]
impl SlowVendor {
pub fn vendor(&self) -> &'static str {
self.inner.vendor()
}
pub fn markets(&self) -> &[Market] {
self.inner.markets()
}
pub fn reserve(&self, req: &ChargeRequest) -> Result<ChargeHandle, PayError> {
self.inner.reserve(req)
}
pub fn draw(&self, h: &ChargeHandle) -> Result<ChargeStatus, PayError> {
self.inner.draw(h)
}
pub fn unlock(&self, h: &ChargeHandle) -> Result<ChargeStatus, PayError> {
self.inner.unlock(h)
}
pub fn charge_status(&self, h: &ChargeHandle) -> Result<ChargeStatus, PayError> {
self.inner.charge_status(h)
}
pub fn parse_callback(&self, raw: RawCallback<'_>) -> Result<CallbackEvent, PayError> {
self.inner.parse_callback(raw)
}
pub fn give_back(&self, h: &ChargeHandle, amount: Money) -> Result<ChargeStatus, PayError> {
self.inner.give_back(h, amount)
}
pub fn settled(&self, h: &ChargeHandle) -> Option<Settlement> {
self.inner.settled(h)
}
pub fn redirect(&self, h: &ChargeHandle) -> Option<String> {
self.inner.redirect(h)
}
pub fn start_signup(&self, order: &SignupOrder) -> Result<Option<SignupCheckout>, PayError> {
std::thread::sleep(self.mint);
self.inner.start_signup(order)
}
}
#[allow(unused_variables)]
impl monetize_pay::payment::Payment for SlowVendor {
fn manifest(&self) -> monetize_pay::payment::PayManifest {
monetize_pay::payment::PayManifest { vendor: self.vendor().to_string(), markets: self.markets().to_vec(), settings: Vec::new(), holds: true, test_payer: None }
}
fn page(&self, charge: &monetize_pay::payment::Charge<'_>, back: &monetize_pay::payment::Return) -> Result<monetize_pay::payment::Page, monetize_pay::PayError> {
Ok(match charge {
monetize_pay::payment::Charge::Order(req) => monetize_pay::payment::Page::Order { handle: self.reserve(req)?, url: None },
monetize_pay::payment::Charge::Signup(o) => monetize_pay::payment::Page::Signup(self.start_signup(o)?),
monetize_pay::payment::Charge::CardCheck(o) => monetize_pay::payment::Page::CardCheck(None),
monetize_pay::payment::Charge::TestSignup(o) => monetize_pay::payment::Page::TestSignup(None),
})
}
fn status(&self, of: &monetize_pay::payment::Of<'_>) -> Result<monetize_pay::payment::Standing, monetize_pay::PayError> {
Ok(match of {
monetize_pay::payment::Of::Charge(h) => monetize_pay::payment::Standing::Charge(self.charge_status(h)?),
monetize_pay::payment::Of::Page(h) => monetize_pay::payment::Standing::Page(self.redirect(h)),
monetize_pay::payment::Of::Signup { session } => monetize_pay::payment::Standing::Signup(None),
monetize_pay::payment::Of::CardCheck { session } => monetize_pay::payment::Standing::CardCheck(None),
})
}
fn capture(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
self.draw(h)
}
fn release(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
self.unlock(h)
}
fn refund(&self, h: &monetize_pay::ChargeHandle, amount: monetize_pay::Money) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
self.give_back(h, amount)
}
fn callback(&self, raw: monetize_pay::RawCallback<'_>) -> Result<monetize_pay::CallbackEvent, monetize_pay::PayError> {
self.parse_callback(raw)
}
fn settlement(&self, h: &monetize_pay::ChargeHandle) -> Option<monetize_pay::Settlement> {
self.settled(h)
}
fn paid_since(&self, since_unix_ms: i64) -> Result<Vec<monetize_pay::reconcile::PaidAtVendor>, monetize_pay::PayError> {
Err(monetize_pay::reconcile::cannot_be_asked())
}
fn livemode(&self) -> Option<bool> {
None
}
}
/// A sink that holds nobody, so every name is free.
struct FreeSink;
impl monetize_pay::SignupSink for FreeSink {
fn paid(&self, s: &monetize_pay::PaidSignup) -> Result<monetize_pay::SignupOutcome, monetize_pay::SignupError> {
Ok(monetize_pay::SignupOutcome::Created { namespace: s.username.clone(), username: s.username.clone() })
}
fn name_verdict(&self, _: &str) -> Result<monetize_pay::NameVerdict, monetize_pay::SignupError> {
Ok(monetize_pay::NameVerdict::Free)
}
}
struct Rig {
state: Arc<AppState>,
product: Arc<MockProduct>,
pay: CallbackPay,
serial: Arc<Mutex<()>>,
ops: Arc<AtomicU64>,
server: TestServer,
channel: Channel,
dir: std::path::PathBuf,
}
impl Rig {
/// test2's front: gunnar linked, a 37 GiB two-member set the UpCloud guest
/// can grow under a 50 EUR monthly cap, Stripe as the vendor — and the
/// state ARMED exactly as `cli::serve` arms it (`grow::arm_reach`).
async fn test2(name: &str) -> Rig {
let dir = std::env::temp_dir().join(format!("monetize-signup-latency-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let product = Arc::new(MockProduct::gunnar());
product.grows(vec!["done"], true);
let mock = Arc::new(MockCloud::new());
mock.grows(GrowScript { current_gib: 37, per_gib_minor: 23, ..GrowScript::default() });
let serial = Arc::new(Mutex::new(()));
let ops = Arc::new(AtomicU64::new(0));
let slow = SlowCloud { inner: mock, serial: serial.clone(), ops: ops.clone() };
let capped = SpendCap::new(slow, Ceiling::monthly(MONTHLY_MINOR, Currency::Eur), dir.join("cap.json")).unwrap();
let pay = CallbackPay::with_checkout("stripe-shared-secret", "https://checkout.stripe.com/c/pay/");
let vendor = SlowVendor { inner: pay.clone(), mint: PER_MINT };
let product_dyn: Arc<dyn Product> = product.clone();
let mut state = AppState::in_memory(Registry::new().sells("gunnar", vendor).shared_product(product_dyn).cloud_capped(capped));
state.signup_plans = [(PLAN.to_string(), SignupPlan { amount_minor: 10_000, currency: Currency::Sek, cap_bytes: 10 * GIB, period_days: 30 })].into();
state.signup_sink = Arc::new(FreeSink);
state.signup_max_sold_percents = std::collections::BTreeMap::from([("gunnar".to_string(), 600)]);
let mut runs = GrowRuns::default();
runs.add("gunnar", GrowSetConfig { cloud: "mockcloud".into(), server: "srv-1".into(), members: vec!["vol-1".into(), "vol-2".into()] }, None, None).unwrap();
state.grow = runs;
// The poller's measurement: 37 GiB, all free, and the set grows at runtime.
let growth = Growth::AtRuntime("this appliance grows at runtime".into());
product.can_serve_growing(37 * GIB, 37 * GIB, growth.clone());
state.health.set_servable("gunnar", Servable::Measured { servable_bytes: 37 * GIB, total_bytes: 37 * GIB, growth, ceilings: Default::default() }, 0);
let state = Arc::new(state);
monetize_server::grow::arm_reach(&state);
let server = TestServer::spawn(state.clone(), BEARER).await;
let channel = Channel::from_shared(server.endpoint()).unwrap().connect().await.expect("connect");
Rig { state, product, pay, serial, ops, server, channel, dir }
}
fn client(&self) -> SignupClient<tonic::service::interceptor::InterceptedService<Channel, impl tonic::service::Interceptor>> {
let bearer: MetadataValue<_> = format!("Bearer {BEARER}").parse().unwrap();
SignupClient::with_interceptor(self.channel.clone(), move |mut r: Request<()>| {
r.metadata_mut().insert("authorization", bearer.clone());
Ok(r)
})
}
/// One `Signup.Start` as the console sends it, timed, and cut off at
/// `limit` so a RED run says how long it waited instead of hanging.
async fn start(&self, user: &str, limit: Duration) -> (Duration, Result<pb::StartSignupResponse, tonic::Status>) {
let began = Instant::now();
let mut client = self.client();
let call = client.start(pb::StartSignupRequest { product: "gunnar".into(),
username: user.into(),
email: format!("{user}@example.com"),
success_url: "https://test2.gunnar.rs/welcome?session={session}".into(),
cancel_url: "https://test2.gunnar.rs/signup".into(),
..Default::default()
});
let out = match tokio::time::timeout(limit, call).await {
Ok(r) => r.map(|r| r.into_inner()),
Err(_) => Err(tonic::Status::deadline_exceeded(format!("the test gave up after {limit:?}"))),
};
(began.elapsed(), out)
}
/// Wait (bounded) until nothing is being done at the cloud — so a
/// measurement starts from a quiet estate.
fn quiet(&self) {
let began = Instant::now();
loop {
let before = self.ops.load(Ordering::SeqCst);
std::thread::sleep(PER_OP * 2);
if self.ops.load(Ordering::SeqCst) == before || began.elapsed() > Duration::from_secs(60) {
return;
}
}
}
async fn stop(self) {
self.server.stop().await;
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// ★ **THE CUSTOMER IS NOT MADE TO WAIT FOR A READING THAT REFUSES NOTHING.**
///
/// RED before the fix: 7.5 s on this bench (12 cloud ops × 600 ms, then the
/// mint), 25-33 s on test2's real UpCloud API. The target is under 2 s.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn signup_start_on_a_slow_cloud_answers_in_under_two_seconds() {
let rig = Rig::test2("slow").await;
rig.quiet();
let (took, out) = rig.start("henrik", Duration::from_secs(60)).await;
eprintln!("MEASURED signup_start on a slow cloud: {} ms", took.as_millis());
let out = out.expect("a box with room sells");
assert!(out.url.starts_with("https://checkout.stripe.com/"), "{}", out.url);
assert!(took < Duration::from_secs(2), "Signup.Start took {took:?} on a cloud that answers an op in {PER_OP:?}");
assert_eq!(rig.pay.signup_orders().len(), 1);
rig.stop().await;
}
/// ★ **A CLOUD BUSY WITH A GROWTH DOES NOT HOLD THE CUSTOMER.**
///
/// The owner's third live signup (22:23:17) never answered: nothing after the
/// capacity line for more than 100 s. A growth holds the cloud guest's one lock
/// for as long as the provider takes to detach, resize and re-attach — minutes.
/// Here the lock is held for 30 s; the signup must still mint in under 2 s.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_cloud_busy_with_a_growth_does_not_hold_a_signup() {
let rig = Rig::test2("busy").await;
rig.quiet();
let serial = rig.serial.clone();
let (held_tx, held_rx) = std::sync::mpsc::channel();
let growth = std::thread::spawn(move || {
let _g = serial.lock().unwrap();
held_tx.send(()).unwrap();
std::thread::sleep(Duration::from_secs(30));
});
held_rx.recv().unwrap();
// The reading goes stale while the growth runs: the next customer is the
// one who would have asked the cloud.
std::thread::sleep(monetize_server::grow::REACH_TTL.min(Duration::from_secs(1)));
monetize_server::grow::expire_readings_for_test(&rig.state);
let (took, out) = rig.start("olle", Duration::from_secs(20)).await;
eprintln!("MEASURED signup_start while a growth holds the cloud: {} ms", took.as_millis());
let out = out.expect("a box with room sells while the set grows");
assert!(out.url.starts_with("https://checkout.stripe.com/"), "{}", out.url);
assert!(took < Duration::from_secs(2), "Signup.Start waited {took:?} behind a growth");
drop(growth);
rig.stop().await;
}
/// **The reading still reaches the operator** — off the customer's path. The
/// RED `shop-past-the-wallet` row (STINK S2) is only worth anything if it is
/// still computed: here the background refresh fills it, and the number is
/// the same arithmetic as before (37 GiB, two members, 50 EUR a month at 23
/// EUR-minor a GiB-month: 108 GiB a member, 216 GiB for the set).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_wallet_reading_is_still_made_off_the_customers_path() {
let rig = Rig::test2("reading").await;
let began = Instant::now();
let affordable = loop {
match monetize_server::grow::wallet_reading(&rig.state, "gunnar") {
monetize_server::grow::Wallet::Read(Some(b)) => break b,
_ if began.elapsed() > Duration::from_secs(60) => panic!("the wallet reading never arrived"),
_ => std::thread::sleep(Duration::from_millis(100)),
}
};
assert_eq!(affordable / GIB, 216, "{affordable}");
let gap = monetize_server::oversell::past_the_wallet(&rig.state, "gunnar", 400 * GIB, 37 * GIB).expect("400 GiB of caps is past 37 + 216");
assert_eq!(gap.over / GIB, 400 - 37 - 216, "{gap:?}");
rig.stop().await;
}
/// **A FULL box reads the reach as a SNAPSHOT** (owner 2026-09-30: a signup's
/// capacity decision reads room on hand + promised + growing and never waits
/// for a growth). The set's growth is what makes room here, so the reach
/// decides the sale — from the last reading, while a growth holds the cloud.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_full_box_sells_on_the_reach_snapshot_while_a_growth_holds_the_cloud() {
let rig = Rig::test2("full").await;
rig.quiet();
*rig.product.refuse_absorb.lock().unwrap() = Some("gunnar: the data set is full".into());
let serial = rig.serial.clone();
let (held_tx, held_rx) = std::sync::mpsc::channel();
let growth = std::thread::spawn(move || {
let _g = serial.lock().unwrap();
held_tx.send(()).unwrap();
std::thread::sleep(Duration::from_secs(30));
});
held_rx.recv().unwrap();
monetize_server::grow::expire_readings_for_test(&rig.state);
let (took, out) = rig.start("pelle", Duration::from_secs(20)).await;
eprintln!("MEASURED signup_start on a full box while a growth holds the cloud: {} ms", took.as_millis());
let out = out.expect("the last reading's reach (216 GiB) covers a 10 GiB plan on a full box");
assert!(out.url.starts_with("https://checkout.stripe.com/"), "{}", out.url);
assert!(took < Duration::from_secs(2), "a full box held the customer {took:?}");
drop(growth);
rig.stop().await;
}