|
| 1 | +// SPDX-License-Identifier: BUSL-1.1 |
| 2 | + |
| 3 | +//! Write-admission chokepoint guard for the SPSC enqueue points. |
| 4 | +//! |
| 5 | +//! Both [`crate::bridge::dispatch::Dispatcher::dispatch`] and |
| 6 | +//! `dispatch_to_core` funnel every request into a Data-Plane core here: no |
| 7 | +//! write-class plan may reach a core marked exempt-as-read — that marker means |
| 8 | +//! the plan claimed to be a non-write and skipped the write-admission gate. |
| 9 | +//! Debug builds trip loudly; release builds increment a counter so a future |
| 10 | +//! write path that bypasses the write-admission gate is caught even when the |
| 11 | +//! assertion is compiled out — turning "a missed write path is a silent |
| 12 | +//! serializability hole" into a loud, observable regression. |
| 13 | +
|
| 14 | +use std::sync::atomic::{AtomicU64, Ordering}; |
| 15 | + |
| 16 | +use crate::bridge::envelope::Request; |
| 17 | + |
| 18 | +/// Count of write-class Requests that reached an SPSC enqueue point marked |
| 19 | +/// [`Admission::Exempt`]`(`[`ExemptReason::Read`]`)` — i.e. hand-labeled a |
| 20 | +/// non-write and thus bypassed the write-admission gate. |
| 21 | +/// |
| 22 | +/// [`Admission::Exempt`]: crate::bridge::envelope::Admission::Exempt |
| 23 | +/// [`ExemptReason::Read`]: crate::bridge::envelope::ExemptReason::Read |
| 24 | +/// |
| 25 | +/// Stays at zero by construction: a base-state write is either |
| 26 | +/// [`Admission::Admitted`] by the gate or [`Admission::Exempt`] with |
| 27 | +/// [`ExemptReason::AlreadyOrdered`]. A non-zero value signals a regressed write |
| 28 | +/// path that was marked exempt-as-read. |
| 29 | +/// |
| 30 | +/// [`Admission::Admitted`]: crate::bridge::envelope::Admission::Admitted |
| 31 | +/// [`ExemptReason::AlreadyOrdered`]: crate::bridge::envelope::ExemptReason::AlreadyOrdered |
| 32 | +static WRITES_BYPASSED_ADMISSION_GATE: AtomicU64 = AtomicU64::new(0); |
| 33 | + |
| 34 | +/// Read the count of writes that reached an SPSC enqueue point marked |
| 35 | +/// exempt-as-read (bypassing the write-admission gate). Exposed for the metrics |
| 36 | +/// exporter and tests. |
| 37 | +pub fn writes_bypassed_admission_gate() -> u64 { |
| 38 | + WRITES_BYPASSED_ADMISSION_GATE.load(Ordering::Relaxed) |
| 39 | +} |
| 40 | + |
| 41 | +/// Chokepoint guard shared by both SPSC enqueue points: catch a write-class |
| 42 | +/// plan that reached a Data-Plane core marked exempt-as-read — i.e. bypassed |
| 43 | +/// the write-admission gate while claiming to be a non-write. |
| 44 | +#[inline] |
| 45 | +pub(crate) fn assert_write_admitted(request: &Request) { |
| 46 | + let bypassed = crate::control::server::shared::write_admission::plan_is_write(&request.plan) |
| 47 | + && request.admission.is_exempt_as_read(); |
| 48 | + if bypassed { |
| 49 | + WRITES_BYPASSED_ADMISSION_GATE.fetch_add(1, Ordering::Relaxed); |
| 50 | + } |
| 51 | + debug_assert!( |
| 52 | + !bypassed, |
| 53 | + "a write-class plan reached the SPSC enqueue marked Exempt(Read) — it bypassed the write-admission gate" |
| 54 | + ); |
| 55 | +} |
| 56 | + |
| 57 | +#[cfg(test)] |
| 58 | +mod tests { |
| 59 | + use super::*; |
| 60 | + use crate::bridge::envelope::{Admission, ExemptReason, PhysicalPlan, Priority}; |
| 61 | + use crate::control::server::shared::write_admission::plan_is_write; |
| 62 | + use crate::types::{DatabaseId, ReadConsistency, RequestId, TenantId, TraceId, VShardId}; |
| 63 | + use nodedb_physical::physical_plan::{DocumentOp, KvOp}; |
| 64 | + use std::time::{Duration, Instant}; |
| 65 | + |
| 66 | + /// A write-class plan: `plan_is_write` returns true for `KvOp::Put`. |
| 67 | + fn write_plan() -> PhysicalPlan { |
| 68 | + PhysicalPlan::Kv(KvOp::Put { |
| 69 | + collection: "c".into(), |
| 70 | + key: b"k".to_vec(), |
| 71 | + value: b"v".to_vec(), |
| 72 | + ttl_ms: 0, |
| 73 | + surrogate: nodedb_types::Surrogate::ZERO, |
| 74 | + }) |
| 75 | + } |
| 76 | + |
| 77 | + /// A read-class plan: `plan_is_write` returns false for `DocumentOp::PointGet`. |
| 78 | + fn read_plan() -> PhysicalPlan { |
| 79 | + PhysicalPlan::Document(DocumentOp::PointGet { |
| 80 | + collection: "c".into(), |
| 81 | + document_id: "d".into(), |
| 82 | + surrogate: nodedb_types::Surrogate::ZERO, |
| 83 | + pk_bytes: Vec::new(), |
| 84 | + rls_filters: Vec::new(), |
| 85 | + system_time: nodedb_types::SystemTimeScope::Current, |
| 86 | + valid_at_ms: None, |
| 87 | + }) |
| 88 | + } |
| 89 | + |
| 90 | + fn request_with(plan: PhysicalPlan, admission: Admission) -> Request { |
| 91 | + Request { |
| 92 | + request_id: RequestId::new(1), |
| 93 | + tenant_id: TenantId::new(1), |
| 94 | + database_id: DatabaseId::DEFAULT, |
| 95 | + vshard_id: VShardId::new(0), |
| 96 | + plan, |
| 97 | + deadline: Instant::now() + Duration::from_secs(5), |
| 98 | + priority: Priority::Normal, |
| 99 | + trace_id: TraceId::generate(), |
| 100 | + consistency: ReadConsistency::Strong, |
| 101 | + idempotency_key: None, |
| 102 | + event_source: crate::event::EventSource::User, |
| 103 | + user_roles: Vec::new(), |
| 104 | + user_id: None, |
| 105 | + statement_digest: None, |
| 106 | + txn_id: None, |
| 107 | + wal_lsn: None, |
| 108 | + admission, |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + /// The guard BITES: a write-class plan marked `Exempt(Read)` bypassed the |
| 113 | + /// write-admission gate, so the debug assertion must fire. |
| 114 | + #[test] |
| 115 | + #[should_panic(expected = "bypassed the write-admission gate")] |
| 116 | + fn write_marked_exempt_as_read_trips_guard() { |
| 117 | + let req = request_with(write_plan(), Admission::Exempt(ExemptReason::Read)); |
| 118 | + assert_write_admitted(&req); |
| 119 | + } |
| 120 | + |
| 121 | + /// A write that passed the gate (`Admitted`) does not trip the guard. |
| 122 | + #[test] |
| 123 | + fn admitted_write_does_not_trip() { |
| 124 | + let req = request_with(write_plan(), Admission::Admitted); |
| 125 | + assert_write_admitted(&req); |
| 126 | + } |
| 127 | + |
| 128 | + /// A legitimate read marked `Exempt(Read)` does not trip the guard. |
| 129 | + #[test] |
| 130 | + fn read_marked_exempt_as_read_does_not_trip() { |
| 131 | + let req = request_with(read_plan(), Admission::Exempt(ExemptReason::Read)); |
| 132 | + assert_write_admitted(&req); |
| 133 | + } |
| 134 | + |
| 135 | + /// An already-ordered write (Calvin/Raft-follower/replay) is legitimately |
| 136 | + /// exempt and does not trip the guard. |
| 137 | + #[test] |
| 138 | + fn write_marked_already_ordered_does_not_trip() { |
| 139 | + let req = request_with( |
| 140 | + write_plan(), |
| 141 | + Admission::Exempt(ExemptReason::AlreadyOrdered), |
| 142 | + ); |
| 143 | + assert_write_admitted(&req); |
| 144 | + } |
| 145 | + |
| 146 | + /// Sanity: the write plan is classified as a write and the read plan is not. |
| 147 | + /// The bypass predicate the counter keys on is |
| 148 | + /// `plan_is_write(&plan) && admission.is_exempt_as_read()`; asserting the |
| 149 | + /// predicate directly (rather than calling `assert_write_admitted`, which |
| 150 | + /// panics in debug) verifies the counter-increment condition without the |
| 151 | + /// panic. |
| 152 | + #[test] |
| 153 | + fn plan_is_write_classification_and_bypass_predicate() { |
| 154 | + let w = write_plan(); |
| 155 | + let r = read_plan(); |
| 156 | + assert!(plan_is_write(&w)); |
| 157 | + assert!(!plan_is_write(&r)); |
| 158 | + |
| 159 | + let exempt_read = Admission::Exempt(ExemptReason::Read); |
| 160 | + // The exact condition under which the counter increments and the guard trips. |
| 161 | + assert!(plan_is_write(&w) && exempt_read.is_exempt_as_read()); |
| 162 | + // A read marked exempt-as-read is NOT a bypass. |
| 163 | + assert!(!(plan_is_write(&r) && exempt_read.is_exempt_as_read())); |
| 164 | + // A write marked Admitted is NOT a bypass. |
| 165 | + assert!(!(plan_is_write(&w) && Admission::Admitted.is_exempt_as_read())); |
| 166 | + // A write marked AlreadyOrdered is NOT a bypass. |
| 167 | + assert!( |
| 168 | + !(plan_is_write(&w) |
| 169 | + && Admission::Exempt(ExemptReason::AlreadyOrdered).is_exempt_as_read()) |
| 170 | + ); |
| 171 | + } |
| 172 | +} |
0 commit comments