Skip to main content

vantage_faker/
shape.rs

1//! Backend personality: a [`TableShell`] decorator that makes an in-memory
2//! store behave like any real backend — paged or cursor-driven, sluggish or
3//! flaky, honest or lying.
4//!
5//! [`BackendShape`] is the single place a scenario's personality is written
6//! down; [`ShapedShell`] enforces it. The shell *advertises* exactly the
7//! capabilities the shape lists and *refuses* everything else through the
8//! standard [`TableShell::default_error`] discipline, so a consumer that
9//! ignores capability flags fails loudly instead of silently working against
10//! a backend that production will not provide.
11//!
12//! Everything nondeterministic (latency jitter, fault draws, boundary skew)
13//! draws from one seeded rng, so a scenario with a `seed` replays
14//! identically. Time-based behavior (the offline schedule, cursor expiry)
15//! reads the tokio clock, so tests drive it with `tokio::time::advance`.
16
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::{Arc, Mutex};
19use std::time::Duration;
20
21use async_trait::async_trait;
22use ciborium::Value as CborValue;
23use fake::rand::rngs::StdRng;
24use fake::rand::{RngExt as _, SeedableRng as _};
25use indexmap::IndexMap;
26use tokio::time::Instant;
27use vantage_core::{Result, error};
28use vantage_types::Record;
29use vantage_vista::Vista;
30use vantage_vista::capabilities::VistaCapabilities;
31use vantage_vista::column::Column;
32use vantage_vista::reference::Reference;
33use vantage_vista::source::TableShell;
34
35/// A latency band: each request draws uniformly from `[min, max]`.
36#[derive(Clone, Copy, Debug)]
37pub struct Latency {
38    pub min: Duration,
39    pub max: Duration,
40}
41
42impl Latency {
43    pub fn fixed(d: Duration) -> Self {
44        Self { min: d, max: d }
45    }
46
47    pub fn between(min: Duration, max: Duration) -> Self {
48        Self {
49            min,
50            max: max.max(min),
51        }
52    }
53
54    fn draw(&self, rng: &mut StdRng) -> Duration {
55        if self.max <= self.min {
56            return self.min;
57        }
58        let spread = (self.max - self.min).as_millis() as u64;
59        self.min + Duration::from_millis(rng.random_range(0..=spread))
60    }
61}
62
63/// Per-operation-class latency. Absent class = instant. Real backends are
64/// asymmetric — a fast get-by-id next to a slow list is a personality, not
65/// an accident — so each class is tuned independently.
66#[derive(Clone, Debug, Default)]
67pub struct LatencyModel {
68    pub list: Option<Latency>,
69    pub get: Option<Latency>,
70    pub window: Option<Latency>,
71    pub count: Option<Latency>,
72    /// Added on top of `list`/`window` while a search filter is active —
73    /// the "search is the expensive endpoint" personality.
74    pub search_extra: Option<Latency>,
75}
76
77/// Scheduled unavailability: within every `period`, the backend is down for
78/// the final `down`. The window starts *online* so a scenario's first paint
79/// works, then the outage arrives on schedule.
80#[derive(Clone, Copy, Debug)]
81pub struct Offline {
82    pub down: Duration,
83    pub period: Duration,
84}
85
86/// The vices. All default off.
87#[derive(Clone, Debug, Default)]
88pub struct FaultSchedule {
89    /// Fraction of tolled requests failing with an injected error.
90    pub error_rate: f64,
91    pub offline: Option<Offline>,
92    /// A `fetch_next` token older than this is dead (the 410 case).
93    pub cursor_expiry: Option<Duration>,
94    /// Reported totals are off by this much from the truth (clamped ≥ 0).
95    pub total_lie: i64,
96    /// Windowed fetches occasionally shift their offset by ±1 — the
97    /// duplicate/missing row an offset-paginated API produces under churn.
98    pub boundary_skew: bool,
99}
100
101/// Undeclared payload riding along on every record: `count` extra fields of
102/// `size`-char strings — the fat API response the query didn't ask for.
103/// Applied at generation time (see `FakerCtx`), recorded here so a shape is
104/// the complete personality description.
105#[derive(Clone, Copy, Debug)]
106pub struct ExtraFields {
107    pub count: usize,
108    pub size: usize,
109}
110
111/// The complete personality of one shaped backend.
112#[derive(Clone, Debug)]
113pub struct BackendShape {
114    /// Exactly what the backend advertises; everything else is refused.
115    pub capabilities: VistaCapabilities,
116    /// The server's page for `fetch_page`/`fetch_next` (and the starting
117    /// size where `can_set_page_size` allows negotiation).
118    pub page_size: usize,
119    pub latency: LatencyModel,
120    pub faults: FaultSchedule,
121    pub extra_fields: Option<ExtraFields>,
122    /// Fraction of generated string cells drawn from the anomaly pool.
123    pub weirdness: f64,
124    /// Deterministic replay when set — drives value generation, fault draws
125    /// and latency jitter alike.
126    pub seed: Option<u64>,
127}
128
129impl Default for BackendShape {
130    /// The classic `MockShell` profile: full CRUD, order, search, no paging,
131    /// no faults, instant.
132    fn default() -> Self {
133        Self {
134            capabilities: VistaCapabilities {
135                can_count: true,
136                can_insert: true,
137                can_update: true,
138                can_delete: true,
139                can_order: true,
140                can_search: true,
141                ..VistaCapabilities::default()
142            },
143            page_size: 25,
144            latency: LatencyModel::default(),
145            faults: FaultSchedule::default(),
146            extra_fields: None,
147            weirdness: 0.0,
148            seed: None,
149        }
150    }
151}
152
153/// Which latency band a request draws from.
154#[derive(Clone, Copy)]
155enum OpClass {
156    List,
157    Get,
158    Window,
159    Count,
160}
161
162/// [`TableShell`] decorator enforcing a [`BackendShape`] over an inner shell
163/// (in practice a `MockShell` clone sharing the effect's store).
164pub struct ShapedShell {
165    inner: Box<dyn TableShell>,
166    shape: Arc<BackendShape>,
167    /// One stream for jitter and fault draws, shared across clones so a
168    /// seeded run is a single deterministic sequence.
169    rng: Arc<Mutex<StdRng>>,
170    /// Time zero for the offline schedule and cursor-token ages.
171    epoch: Instant,
172    /// Current negotiated page size — per handle, like any query state.
173    page_size: usize,
174    /// Whether a search filter is active on THIS handle (adds
175    /// `search_extra` latency to list/window tolls). Arc'd only so `&self`
176    /// async methods can observe what `&mut self` setters flipped.
177    searching: Arc<AtomicBool>,
178}
179
180impl ShapedShell {
181    pub fn new(inner: Box<dyn TableShell>, shape: BackendShape) -> Self {
182        // Decorrelate from ValueGen's stream: same seed must not make the
183        // 3rd fault draw equal the 3rd generated cell.
184        let rng = match shape.seed {
185            Some(seed) => StdRng::seed_from_u64(seed ^ 0x5AAD_F00D_u64),
186            None => crate::value_gen::entropy_rng(),
187        };
188        let page_size = shape.page_size.max(1);
189        Self {
190            inner,
191            shape: Arc::new(shape),
192            rng: Arc::new(Mutex::new(rng)),
193            epoch: Instant::now(),
194            page_size,
195            searching: Arc::new(AtomicBool::new(false)),
196        }
197    }
198
199    /// Pay a request's toll: scheduled outage, then latency, then the error
200    /// draw — an offline backend refuses fast, a flaky one fails slowly,
201    /// like their real counterparts.
202    ///
203    /// Every tolled request logs at debug under `vantage_faker::shape` —
204    /// the tap the select tester's request accounting reads.
205    async fn toll(&self, class: OpClass) -> Result<()> {
206        if let Some(off) = self.shape.faults.offline {
207            let elapsed = self.epoch.elapsed();
208            let period = off.period.max(off.down);
209            let into_period =
210                Duration::from_nanos((elapsed.as_nanos() % period.as_nanos().max(1)) as u64);
211            if into_period >= period.saturating_sub(off.down) {
212                return Err(error!("shaped source is offline (scheduled outage)"));
213            }
214        }
215
216        let (delay, failed) = {
217            let rng = &mut *self.rng.lock().unwrap();
218            let band = match class {
219                OpClass::List => self.shape.latency.list,
220                OpClass::Get => self.shape.latency.get,
221                OpClass::Window => self.shape.latency.window,
222                OpClass::Count => self.shape.latency.count,
223            };
224            let mut delay = band.map(|b| b.draw(rng)).unwrap_or_default();
225            if matches!(class, OpClass::List | OpClass::Window)
226                && self.searching.load(Ordering::Relaxed)
227                && let Some(extra) = self.shape.latency.search_extra
228            {
229                delay += extra.draw(rng);
230            }
231            let failed = self.shape.faults.error_rate > 0.0
232                && rng.random_range(0.0..1.0) < self.shape.faults.error_rate;
233            (delay, failed)
234        };
235
236        if !delay.is_zero() {
237            tokio::time::sleep(delay).await;
238        }
239        if failed {
240            return Err(error!("shaped source request failed (injected fault)"));
241        }
242        Ok(())
243    }
244
245    /// Offset after the boundary-skew vice: occasionally ±1, producing the
246    /// duplicated or missing row of offset pagination under churn.
247    fn skewed_offset(&self, offset: usize) -> usize {
248        if !self.shape.faults.boundary_skew || offset == 0 {
249            return offset;
250        }
251        let draw: f64 = self.rng.lock().unwrap().random_range(0.0..1.0);
252        if draw < 0.2 {
253            offset - 1 // last row of the previous window repeats
254        } else if draw < 0.4 {
255            offset + 1 // one row is silently skipped
256        } else {
257            offset
258        }
259    }
260
261    /// Reported total = truth + the configured lie, clamped to zero.
262    async fn lied_total(&self, vista: &Vista) -> Result<i64> {
263        let truth = self.inner.get_vista_count(vista).await?;
264        Ok((truth + self.shape.faults.total_lie).max(0))
265    }
266
267    fn cursor_token(&self, offset: usize) -> CborValue {
268        CborValue::Array(vec![
269            CborValue::Integer((offset as i64).into()),
270            CborValue::Integer((self.epoch.elapsed().as_millis() as i64).into()),
271        ])
272    }
273
274    fn decode_cursor(&self, token: &CborValue) -> Result<usize> {
275        let CborValue::Array(parts) = token else {
276            return Err(error!("shaped source: malformed cursor token"));
277        };
278        let (Some(CborValue::Integer(offset)), Some(CborValue::Integer(issued_ms))) =
279            (parts.first(), parts.get(1))
280        else {
281            return Err(error!("shaped source: malformed cursor token"));
282        };
283        if let Some(expiry) = self.shape.faults.cursor_expiry {
284            let issued = Duration::from_millis(i128::from(*issued_ms).max(0) as u64);
285            if self.epoch.elapsed().saturating_sub(issued) > expiry {
286                return Err(error!("shaped source: cursor token expired"));
287            }
288        }
289        Ok(i128::from(*offset).max(0) as usize)
290    }
291
292    fn gate(&self, allowed: bool, method: &str, capability: &str) -> Result<()> {
293        if allowed {
294            Ok(())
295        } else {
296            Err(self.default_error(method, capability))
297        }
298    }
299}
300
301#[async_trait]
302#[allow(clippy::ptr_arg)]
303impl TableShell for ShapedShell {
304    fn columns(&self) -> &IndexMap<String, Column> {
305        self.inner.columns()
306    }
307
308    fn references(&self) -> &IndexMap<String, Reference> {
309        self.inner.references()
310    }
311
312    fn id_column(&self) -> Option<&str> {
313        self.inner.id_column()
314    }
315
316    fn capabilities(&self) -> &VistaCapabilities {
317        &self.shape.capabilities
318    }
319
320    fn driver_name(&self) -> &'static str {
321        "faker-shaped"
322    }
323
324    async fn list_vista_values(
325        &self,
326        vista: &Vista,
327    ) -> Result<IndexMap<String, Record<CborValue>>> {
328        tracing::debug!(target: "vantage_faker::shape", op = "list", "request");
329        self.toll(OpClass::List).await?;
330        self.inner.list_vista_values(vista).await
331    }
332
333    async fn get_vista_value(
334        &self,
335        vista: &Vista,
336        id: &String,
337    ) -> Result<Option<Record<CborValue>>> {
338        tracing::debug!(target: "vantage_faker::shape", op = "get", id = %id, "request");
339        self.toll(OpClass::Get).await?;
340        self.inner.get_vista_value(vista, id).await
341    }
342
343    async fn get_vista_some_value(
344        &self,
345        vista: &Vista,
346    ) -> Result<Option<(String, Record<CborValue>)>> {
347        self.toll(OpClass::Get).await?;
348        self.inner.get_vista_some_value(vista).await
349    }
350
351    async fn fetch_window(
352        &self,
353        vista: &Vista,
354        offset: usize,
355        limit: usize,
356    ) -> Result<Vec<(String, Record<CborValue>)>> {
357        self.gate(
358            self.shape.capabilities.can_fetch_window,
359            "fetch_window",
360            "can_fetch_window",
361        )?;
362        tracing::debug!(target: "vantage_faker::shape", op = "window", offset, limit, "request");
363        self.toll(OpClass::Window).await?;
364        let offset = self.skewed_offset(offset);
365        self.inner.fetch_window(vista, offset, limit).await
366    }
367
368    /// The total rides the same response envelope as the rows (no second
369    /// toll) — and only exists where the shape can count.
370    async fn fetch_window_counted(
371        &self,
372        vista: &Vista,
373        offset: usize,
374        limit: usize,
375    ) -> Result<(Vec<(String, Record<CborValue>)>, Option<i64>)> {
376        let rows = self.fetch_window(vista, offset, limit).await?;
377        let total = if self.shape.capabilities.can_count {
378            Some(self.lied_total(vista).await?)
379        } else {
380            None
381        };
382        Ok((rows, total))
383    }
384
385    async fn fetch_page(
386        &self,
387        vista: &Vista,
388        page: usize,
389    ) -> Result<Vec<(String, Record<CborValue>)>> {
390        self.gate(
391            self.shape.capabilities.can_fetch_page,
392            "fetch_page",
393            "can_fetch_page",
394        )?;
395        self.toll(OpClass::Window).await?;
396        let offset = self.skewed_offset(page.saturating_sub(1) * self.page_size);
397        self.inner.fetch_window(vista, offset, self.page_size).await
398    }
399
400    async fn fetch_next(
401        &self,
402        vista: &Vista,
403        token: Option<CborValue>,
404    ) -> Result<(Vec<(String, Record<CborValue>)>, Option<CborValue>)> {
405        self.gate(
406            self.shape.capabilities.can_fetch_next,
407            "fetch_next",
408            "can_fetch_next",
409        )?;
410        self.toll(OpClass::Window).await?;
411        let offset = match &token {
412            None => 0,
413            Some(t) => self.decode_cursor(t)?,
414        };
415        let offset = self.skewed_offset(offset);
416        let rows = self
417            .inner
418            .fetch_window(vista, offset, self.page_size)
419            .await?;
420        let next = (rows.len() == self.page_size).then(|| self.cursor_token(offset + rows.len()));
421        Ok((rows, next))
422    }
423
424    async fn get_vista_count(&self, vista: &Vista) -> Result<i64> {
425        self.gate(
426            self.shape.capabilities.can_count,
427            "get_vista_count",
428            "can_count",
429        )?;
430        tracing::debug!(target: "vantage_faker::shape", op = "count", "request");
431        self.toll(OpClass::Count).await?;
432        self.lied_total(vista).await
433    }
434
435    // ---- Writes: forwarded when advertised, no toll — the scenarios stress
436    // the read path; write latency is a personality nobody asked for yet.
437
438    async fn insert_vista_value(
439        &self,
440        vista: &Vista,
441        id: &String,
442        record: &Record<CborValue>,
443    ) -> Result<Record<CborValue>> {
444        self.gate(
445            self.shape.capabilities.can_insert,
446            "insert_vista_value",
447            "can_insert",
448        )?;
449        self.inner.insert_vista_value(vista, id, record).await
450    }
451
452    async fn replace_vista_value(
453        &self,
454        vista: &Vista,
455        id: &String,
456        record: &Record<CborValue>,
457    ) -> Result<Record<CborValue>> {
458        self.gate(
459            self.shape.capabilities.can_update,
460            "replace_vista_value",
461            "can_update",
462        )?;
463        self.inner.replace_vista_value(vista, id, record).await
464    }
465
466    async fn patch_vista_value(
467        &self,
468        vista: &Vista,
469        id: &String,
470        partial: &Record<CborValue>,
471    ) -> Result<Record<CborValue>> {
472        self.gate(
473            self.shape.capabilities.can_update,
474            "patch_vista_value",
475            "can_update",
476        )?;
477        self.inner.patch_vista_value(vista, id, partial).await
478    }
479
480    async fn delete_vista_value(&self, vista: &Vista, id: &String) -> Result<()> {
481        self.gate(
482            self.shape.capabilities.can_delete,
483            "delete_vista_value",
484            "can_delete",
485        )?;
486        self.inner.delete_vista_value(vista, id).await
487    }
488
489    async fn delete_vista_all_values(&self, vista: &Vista) -> Result<()> {
490        self.gate(
491            self.shape.capabilities.can_delete,
492            "delete_vista_all_values",
493            "can_delete",
494        )?;
495        self.inner.delete_vista_all_values(vista).await
496    }
497
498    async fn insert_vista_return_id_value(
499        &self,
500        vista: &Vista,
501        record: &Record<CborValue>,
502    ) -> Result<String> {
503        self.gate(
504            self.shape.capabilities.can_insert,
505            "insert_vista_return_id_value",
506            "can_insert",
507        )?;
508        self.inner.insert_vista_return_id_value(vista, record).await
509    }
510
511    // ---- Query state ------------------------------------------------------
512
513    fn add_eq_condition(&mut self, field: &str, value: &CborValue) -> Result<()> {
514        // Equality push-down is universal — not gated, per the capability
515        // contract.
516        self.inner.add_eq_condition(field, value)
517    }
518
519    fn add_search(&mut self, text: &str) -> Result<()> {
520        self.gate(
521            self.shape.capabilities.can_search,
522            "add_search",
523            "can_search",
524        )?;
525        self.inner.add_search(text)?;
526        self.searching.store(true, Ordering::Relaxed);
527        Ok(())
528    }
529
530    fn clear_search(&mut self) -> Result<()> {
531        self.gate(
532            self.shape.capabilities.can_search,
533            "clear_search",
534            "can_search",
535        )?;
536        self.inner.clear_search()?;
537        self.searching.store(false, Ordering::Relaxed);
538        Ok(())
539    }
540
541    fn add_order(&mut self, field: &str, dir: vantage_vista::sort::SortDirection) -> Result<()> {
542        self.gate(self.shape.capabilities.can_order, "add_order", "can_order")?;
543        self.inner.add_order(field, dir)
544    }
545
546    fn clear_orders(&mut self) -> Result<()> {
547        self.gate(
548            self.shape.capabilities.can_order,
549            "clear_orders",
550            "can_order",
551        )?;
552        self.inner.clear_orders()
553    }
554
555    fn set_page_size(&mut self, size: usize) -> Result<()> {
556        self.gate(
557            self.shape.capabilities.can_set_page_size,
558            "set_page_size",
559            "can_set_page_size",
560        )?;
561        self.page_size = size.max(1);
562        Ok(())
563    }
564
565    /// Same store and fault/jitter stream, fresh query state — the clone a
566    /// consumer narrows (`add_order` + `fetch_window`) without disturbing
567    /// this handle. Each clone tracks its own `searching`.
568    fn clone_shell(&self) -> Option<Box<dyn TableShell>> {
569        let inner = self.inner.clone_shell()?;
570        Some(Box::new(Self {
571            inner,
572            shape: self.shape.clone(),
573            rng: self.rng.clone(),
574            epoch: self.epoch,
575            page_size: self.page_size,
576            searching: Arc::new(AtomicBool::new(false)),
577        }))
578    }
579}