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}