Skip to main content

beam/
ack.rs

1//! Network fanout acknowledgement policy and result types.
2//!
3//! Implements Gun.js ask-pattern semantics for multi-peer replication: a
4//! requester sends a [`Message::Put`](crate::message::Message::Put) to the
5//! router, the router registers the put as a quorum-tracked write via
6//! [`Message::RegisterQuorum`](crate::message::Message::RegisterQuorum),
7//! the router fans the put out to peers, and tracks the per-peer acks
8//! until an [`AckPolicy`] quorum is satisfied.
9//!
10//! # Wire format (Gun.js compatible)
11//!
12//! Peer acks reuse the existing `Put { in_response_to: Some(put_id), .. }`
13//! wire — the `@` field in serialized JSON. Quorum completion is signalled
14//! by a **`__quorum_met__` sentinel** in the reply's `updated_nodes`, with
15//! the ack count as the value. This mirrors the existing `_ack`/`_err`
16//! sentinel convention used by [`Node::put`](crate::Node::put) and
17//! [`Node::batch_put`](crate::Node::batch_put), so callers can use the
18//! same drain plumbing.
19//!
20//! # Reserved sentinel prefix
21//!
22//! `__beam__` is the reserved prefix for wire-level sentinels. Existing:
23//! - `__beam_replay_complete__` — emitted by `map()` replay to signal drain complete
24//!
25//! New:
26//! - `__quorum_met__` — emitted by Router when quorum threshold is reached
27//!
28//! The `__` prefix is filtered during normal data iteration (see
29//! `Node::handle_put` reserved-prefix handling), so these sentinels never
30//! collide with user data.
31//!
32//! # Lifecycle
33//!
34//! ```text
35//!   Node::put_quorum(value, policy)
36//!     ├── build Put, register oneshot in pending_puts
37//!     ├── send Message::RegisterQuorum { put_id, requester, policy } to Router
38//!     ├── send Message::Put(put) to Router
39//!     │     ↓
40//!     │   Router creates internal QuorumEntry for put_id
41//!     │   Router::handle_put_relay fans out to peers (same as fire-and-forget)
42//!     │     ↓
43//!     │   Each peer eventually replies with Put { @: put_id, .. }
44//!     │     ↓
45//!     │   Router::handle_put sees Put.@, finds QuorumEntry, increments counter
46//!     │   When counter >= policy.quorum → Router sends reply back to requester:
47//!     │     Put { @: put_id, updated_nodes: { "__quorum_met__": ack_count } }
48//!     └── requester's oneshot resolves with ReplicationStatus
49//! ```
50//!
51//! # Why a sentinel, not a new Result variant
52//!
53//! The codebase has converged on sentinel-drain as the canonical ack
54//! pattern (see `feat/beam-redux-async-ack-and-drain` branch):
55//!
56//! - `_ack`/`_err` sentinels for storage commit confirmation
57//! - `__beam_replay_complete__` sentinel for replay drain
58//!
59//! Adding `__quorum_met__` keeps the drain plumbing DRY — the same
60//! `pending_puts: Arc<RwLock<HashMap<String, oneshot::Sender<...>>>>` map
61//! and `tokio::time::timeout` envelope that [`Node::put`](crate::Node::put)
62//! uses are reused for [`Node::put_quorum`](crate::Node::put_quorum).
63//! Only the *decoder* differs: instead of looking for `_ack`/`_err`,
64//! `put_quorum` looks for `__quorum_met__`.
65//!
66//! # Quorum policies
67//!
68//! - [`AckPolicy::any`] — first ack wins (Gun.js default, fastest)
69//! - [`AckPolicy::for_peer_count`] — ⌈N/2⌉ majority (Raft/Dynamo style)
70//! - [`AckPolicy::all`] — every fan-out target must ack
71//!
72//! # Timeout
73//!
74//! Default timeout matches Gun.js `lack = 9000ms`. Configurable via
75//! [`AckPolicy::with_timeout`]. On timeout the requester's oneshot resolves
76//! with `Err("put_quorum timed out")` and the Router's internal
77//! `QuorumEntry` is reaped lazily (removed on next access by another put
78//! for the same id, or by the Router's periodic cleanup if added later).
79//!
80//! # Design constraints
81//!
82//! - **No new Message variant for the ack wire** — reuses
83//!   `Put { in_response_to, .. }` for ack routing
84//! - **One new Message variant: `RegisterQuorum`** — minimal struct with
85//!   `(put_id, requester_addr, policy)`; used by the Router to create the
86//!   `QuorumEntry` before fan-out
87//! - **Sentinel-driven completion** — `__quorum_met__` in `updated_nodes`,
88//!   matching the `_ack`/`_err` convention
89//! - **No new dependency** — uses existing `std`, `tokio`
90
91use std::time::Duration;
92
93/// Reserved sentinel key emitted by the Router when quorum threshold is met.
94///
95/// Stored in the reply Put's `updated_nodes` map with the ack count as the
96/// value, e.g. `{"__quorum_met__": 3}` means 3 peers acked.
97///
98/// The `__` prefix is filtered during normal data iteration so this
99/// sentinel never collides with user data.
100pub const QUORUM_MET_SENTINEL: &str = "__quorum_met__";
101
102/// Default timeout for quorum requests, matching Gun.js `lack = 9000ms`.
103///
104/// Gun.js's original ask pattern uses a 9-second lack to bound how long a
105/// requester waits for an ack. We adopt the same default for wire-level
106/// compatibility, but callers can override via
107/// [`AckPolicy::with_timeout`].
108pub const DEFAULT_QUORUM_TIMEOUT: Duration = Duration::from_millis(9000);
109
110/// Policy controlling how many peer acks satisfy a `put_quorum` request.
111///
112/// Construct via the associated functions ([`any`](Self::any),
113/// [`for_peer_count`](Self::for_peer_count), [`all`](Self::all)) which
114/// pick sensible default timeouts. Override the timeout via
115/// [`with_timeout`](Self::with_timeout).
116///
117/// # Examples
118///
119/// ```ignore
120/// // Gun.js default — first ack wins, 9s timeout
121/// let p = AckPolicy::any();
122///
123/// // Majority of 5 peers — 3 acks needed, 9s timeout
124/// let p = AckPolicy::for_peer_count(5);
125///
126/// // All fanned-out peers must ack
127/// let p = AckPolicy::all();
128///
129/// // Any ack, but with a tighter 2s deadline
130/// let p = AckPolicy::any().with_timeout(Duration::from_secs(2));
131/// ```
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub struct AckPolicy {
134    /// Number of peer acks required to satisfy the policy.
135    ///
136    /// `1` = any, `usize::MAX` = all (effective bound: fanned-out peer count).
137    pub quorum: usize,
138    /// Maximum time to wait before the request resolves with `Err`.
139    pub timeout: Duration,
140}
141
142impl Default for AckPolicy {
143    /// Defaults to [`AckPolicy::any`] — first ack wins, Gun.js compatible.
144    fn default() -> Self {
145        Self::any()
146    }
147}
148
149impl AckPolicy {
150    /// "First ack wins" policy with the Gun.js default 9-second timeout.
151    ///
152    /// This matches the behaviour of Gun.js's `ask` when no quorum is
153    /// specified — the requester resolves as soon as any peer or the
154    /// local storage commits the put.
155    pub fn any() -> Self {
156        Self {
157            quorum: 1,
158            timeout: DEFAULT_QUORUM_TIMEOUT,
159        }
160    }
161
162    /// Majority quorum: ⌈N/2⌉ of `peer_count` peers must ack.
163    ///
164    /// Uses the Raft/Dynamo-style majority to balance availability against
165    /// consistency. For `peer_count == 0` or `1`, falls back to
166    /// [`any`](Self::any) since majority is undefined for trivial peer sets.
167    ///
168    /// # Examples
169    ///
170    /// ```ignore
171    /// assert_eq!(AckPolicy::for_peer_count(3).quorum, 2); // 2 of 3
172    /// assert_eq!(AckPolicy::for_peer_count(5).quorum, 3); // 3 of 5
173    /// assert_eq!(AckPolicy::for_peer_count(0).quorum, 1); // → any
174    /// ```
175    pub fn for_peer_count(peer_count: usize) -> Self {
176        let quorum = match peer_count {
177            0 | 1 => 1,
178            n => n.div_ceil(2),
179        };
180        Self {
181            quorum,
182            timeout: DEFAULT_QUORUM_TIMEOUT,
183        }
184    }
185
186    /// "Every fanned-out peer must ack" policy with the default timeout.
187    ///
188    /// Strictest consistency guarantee — the put is only considered durable
189    /// once every target has confirmed. Useful for critical writes where
190    /// partial replication is unacceptable.
191    pub fn all() -> Self {
192        Self {
193            quorum: usize::MAX,
194            timeout: DEFAULT_QUORUM_TIMEOUT,
195        }
196    }
197
198    /// Returns a new policy with the given timeout (other fields preserved).
199    ///
200    /// Useful when callers want a faster-failing deadline than the Gun.js
201    /// default, or a longer grace period for high-latency networks.
202    pub fn with_timeout(mut self, timeout: Duration) -> Self {
203        self.timeout = timeout;
204        Self { ..self }
205    }
206
207    /// Returns a new policy with the given quorum requirement.
208    ///
209    /// Clamped to `>= 1` (a quorum of 0 would resolve immediately, which
210    /// is almost certainly not what the caller intends).
211    pub fn with_quorum(mut self, quorum: usize) -> Self {
212        self.quorum = quorum.max(1);
213        Self { ..self }
214    }
215}
216
217/// Result of a successful `put_quorum` request.
218///
219/// Reports how many peers acked, whether the policy was satisfied, and
220/// how long the request took to resolve. Returned in the `Ok` arm of
221/// `put_quorum`'s `Result`.
222#[derive(Debug, Clone)]
223pub struct ReplicationStatus {
224    /// The id of the originating Put (matches `Put.id`).
225    pub put_id: String,
226    /// Number of peer acks observed before quorum was satisfied.
227    pub acked_by: usize,
228    /// Whether the quorum threshold was met.
229    ///
230    /// Always `true` when returned in the `Ok` arm of `put_quorum` —
231    /// included for symmetry with future APIs that may report partial
232    /// replication status.
233    pub quorum_met: bool,
234    /// Wall-clock duration between put submission and quorum satisfaction.
235    pub elapsed: Duration,
236}
237
238#[cfg(test)]
239mod tests {
240    //! Unit tests for ack policy math.
241    //!
242    //! These tests verify the public policy constructors and their
243    //! invariants. State-tracking tests for the Router's internal
244    //! `QuorumEntry` live in `src/router.rs::tests` since that type is
245    //! private to the router module.
246
247    use super::*;
248    use std::time::Duration;
249
250    #[test]
251    fn policy_any_quorum_is_one() {
252        assert_eq!(AckPolicy::any().quorum, 1);
253        assert_eq!(AckPolicy::any().timeout, DEFAULT_QUORUM_TIMEOUT);
254    }
255
256    #[test]
257    fn policy_default_is_any() {
258        assert_eq!(AckPolicy::default(), AckPolicy::any());
259    }
260
261    #[test]
262    fn policy_for_peer_count_majority() {
263        // Standard Raft/Dynamo majority math.
264        assert_eq!(AckPolicy::for_peer_count(2).quorum, 1); // ⌈2/2⌉ = 1
265        assert_eq!(AckPolicy::for_peer_count(3).quorum, 2); // ⌈3/2⌉ = 2
266        assert_eq!(AckPolicy::for_peer_count(5).quorum, 3); // ⌈5/2⌉ = 3
267        assert_eq!(AckPolicy::for_peer_count(7).quorum, 4); // ⌈7/2⌉ = 4
268    }
269
270    #[test]
271    fn policy_for_peer_count_trivial_falls_back_to_any() {
272        // 0 peers or 1 peer — majority is undefined, fall back to any.
273        assert_eq!(AckPolicy::for_peer_count(0).quorum, 1);
274        assert_eq!(AckPolicy::for_peer_count(1).quorum, 1);
275    }
276
277    #[test]
278    fn policy_all_quorum_is_max() {
279        assert_eq!(AckPolicy::all().quorum, usize::MAX);
280    }
281
282    #[test]
283    fn policy_with_timeout_overrides_default() {
284        let p = AckPolicy::any().with_timeout(Duration::from_secs(2));
285        assert_eq!(p.timeout, Duration::from_secs(2));
286        assert_eq!(p.quorum, 1); // other fields preserved
287    }
288
289    #[test]
290    fn policy_with_quorum_clamps_to_one() {
291        // Quorum of 0 would resolve immediately — almost never intended.
292        let p = AckPolicy::any().with_quorum(0);
293        assert_eq!(p.quorum, 1, "quorum 0 must clamp to 1");
294
295        let p = AckPolicy::any().with_quorum(5);
296        assert_eq!(p.quorum, 5);
297    }
298
299    #[test]
300    fn sentinel_constant_is_stable() {
301        // The wire-format string is load-bearing — any change would
302        // break interop with existing BEAM nodes. Lock it down.
303        assert_eq!(QUORUM_MET_SENTINEL, "__quorum_met__");
304    }
305
306    #[test]
307    fn default_timeout_matches_gun_js_lack() {
308        // Gun.js `lack = 9000ms` is the canonical default. Changing
309        // this would surprise Gun.js interop users.
310        assert_eq!(DEFAULT_QUORUM_TIMEOUT, Duration::from_millis(9000));
311    }
312}