Skip to main content

rhiza_node/
lib.rs

1#![cfg_attr(
2    not(any(feature = "sql", feature = "graph", feature = "kv")),
3    allow(dead_code, unreachable_code, unused_imports, unused_variables)
4)]
5
6use std::{
7    collections::{HashMap, HashSet},
8    fmt, fs,
9    path::{Path, PathBuf},
10    sync::{
11        atomic::{AtomicBool, Ordering},
12        Arc, Condvar, Mutex, MutexGuard, OnceLock,
13    },
14    time::{Duration, Instant},
15};
16
17#[cfg(any(feature = "sql", feature = "kv"))]
18use std::collections::VecDeque;
19
20#[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
21compile_error!("rhiza-node requires at least one execution profile feature: sql, graph, or kv");
22
23#[cfg(feature = "graph")]
24use std::collections::BTreeMap;
25#[cfg(any(feature = "sql", feature = "kv", test))]
26use std::sync::atomic::AtomicUsize;
27
28use axum::{
29    extract::{rejection::JsonRejection, DefaultBodyLimit, Extension, Request, State},
30    http::{HeaderMap, StatusCode},
31    middleware::{self, Next},
32    response::{IntoResponse, Response},
33    routing::{get, post},
34    Json, Router,
35};
36#[cfg(feature = "sql")]
37use rhiza_archive::SnapshotRecord;
38use rhiza_core::{
39    Command, CommandKind, ConfigChange, ConfigurationState, EntryType, ErrorClassification,
40    ExecutionProfile, LogAnchor, LogEntry, LogHash, LogIndex, RecoveryAnchor, StoredCommand,
41};
42#[cfg(feature = "graph")]
43use rhiza_graph::{
44    encode_replicated_graph_batch, encode_replicated_graph_command, CanonicalF64, GraphColumn,
45    GraphInternalId, GraphLogicalType, GraphNode, GraphParameterValue, GraphQueryResult, GraphRel,
46    GraphResultValue, LadybugStateMachine, RequestRecord as GraphRequestRecord,
47};
48#[cfg(feature = "kv")]
49use rhiza_kv::{
50    encode_replicated_kv_batch, encode_replicated_kv_command, KvRequestRecord, RedbStateMachine,
51    MAX_KV_BATCH_MEMBERS,
52};
53#[cfg(feature = "kv")]
54pub use rhiza_kv::{KvScanResult, KvScanRow, MAX_KV_SCAN_RESULT_BYTES, MAX_KV_SCAN_ROWS};
55#[cfg(feature = "sql")]
56use rhiza_log::{decode_segment_for_cluster, write_segment_file};
57use rhiza_log::{FileLogStore, IndexRange, LogStore};
58#[cfg(feature = "sql")]
59use rhiza_obj_store::{ObjStore, ObjStoreConfig};
60#[cfg(feature = "sql")]
61use rhiza_quepaxa::Consensus;
62use rhiza_quepaxa::{
63    CertifiedDecisionInspection, DecisionInspection, DecisionProof, Membership,
64    ReadFenceObservation, ReadFenceRequest, RecordRequest, RecordSummary, RecorderFileStore,
65    RecorderRpc, RejectReason, ThreeNodeConsensus,
66};
67#[cfg(feature = "sql")]
68use rhiza_sql::{
69    decode_qwal_v3, encode_put_request, encode_sql_command, restore_snapshot_file,
70    RecoverySnapshot, RequestConflict, RequestOutcome, SqlBatchMember, SqlCommand,
71    SqlCommandResult, SqlQueryResult, SqlStatement, SqlValue, SqliteStateMachine,
72    MAX_QWAL_V3_RECEIPTS, MAX_SQL_STATEMENTS, QWAL_V3_MAGIC,
73};
74#[cfg(not(feature = "sql"))]
75type SqlCommandResult = ();
76
77mod admin;
78pub mod durability;
79#[cfg(feature = "graph")]
80mod graph;
81#[cfg(feature = "kv")]
82mod kv;
83mod learner;
84mod recorder_tcp;
85pub use admin::*;
86pub use durability::{
87    restore_checkpoint_to_fresh_data_dir, restore_checkpoint_to_fresh_data_dir_for_node,
88    CheckpointCoordinator, DurabilityError, DurabilityHealth, DurabilityMode,
89    SuccessorRestorePreparation,
90};
91#[cfg(feature = "graph")]
92pub use graph::*;
93#[cfg(feature = "kv")]
94pub use kv::*;
95pub use learner::*;
96#[cfg(feature = "recorder-postcard-rpc")]
97pub use recorder_tcp::{
98    serve_recorder_postcard_rpc, serve_recorder_postcard_rpc_tls,
99    RecorderPostcardRpcTlsClientConfig, RecorderPostcardRpcTlsServerConfig,
100    TcpPostcardRpcRecorderClient,
101};
102pub use recorder_tcp::{
103    serve_recorder_tcp, serve_recorder_tcp_tls, validate_recorder_tcp_endpoint,
104    RecorderTlsClientConfig, RecorderTlsServerConfig, TcpPostcardRecorderClient,
105};
106
107pub const MAX_FETCH_ENTRIES: u32 = 1_024;
108pub const MAX_COMMAND_BYTES: usize = 512 * 1024;
109pub const MAX_REQUEST_ID_BYTES: usize = 256;
110pub const MAX_KEY_BYTES: usize = 4 * 1024;
111pub const MAX_VALUE_BYTES: usize = 240 * 1024;
112pub const MAX_HTTP_BODY_BYTES: usize = MAX_COMMAND_BYTES * 6 + 16 * 1024;
113pub const DEFAULT_CLIENT_CONCURRENCY: usize = 16;
114pub const DEFAULT_PEER_CONCURRENCY: usize = 32;
115pub const DEFAULT_WRITER_BATCH_MAX: usize = 8;
116const MAX_WRITE_BATCH_MEMBERS: usize = 64;
117#[cfg(feature = "sql")]
118const MAX_SQL_WRITE_BATCH_MEMBERS: usize = MAX_QWAL_V3_RECEIPTS;
119#[cfg(feature = "sql")]
120pub const MAX_TYPED_SQL_WRITE_BATCH_MEMBERS: usize = 256;
121#[cfg(feature = "sql")]
122pub const DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY: usize = 64;
123#[cfg(feature = "sql")]
124pub const MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY: usize = 4_096;
125// One full 1,024-receipt group is four aggregate-capped public calls.
126#[cfg(feature = "sql")]
127const MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES: usize = 4 * MAX_COMMAND_BYTES;
128// Keep the configured call limit for compatibility, but never retain more than 64 full calls.
129#[cfg(feature = "sql")]
130const MAX_SQL_GROUP_COMMIT_PENDING_BYTES: usize =
131    DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY * MAX_COMMAND_BYTES;
132#[cfg(feature = "kv")]
133const MAX_KV_GROUP_COMMIT_MEMBERS: usize = 1_024;
134#[cfg(feature = "kv")]
135const KV_GROUP_COMMIT_QUEUE_CAPACITY: usize = 64;
136#[cfg(feature = "kv")]
137const MAX_KV_GROUP_COMMIT_PENDING_BYTES: usize = KV_GROUP_COMMIT_QUEUE_CAPACITY * MAX_COMMAND_BYTES;
138pub const DEFAULT_WRITER_BATCH_WINDOW: Duration = Duration::from_micros(500);
139pub const PROTOCOL_VERSION: &str = "1";
140pub const RECORDER_PROTOCOL_VERSION: &str = "3";
141const RECORDER_WIRE_VERSION: u16 = 3;
142pub const VERSION_HEADER: &str = "x-rhiza-version";
143pub const NODE_ID_HEADER: &str = "x-rhiza-node-id";
144pub const RECOVERY_GENERATION_HEADER: &str = "x-rhiza-recovery-generation";
145pub const RECORDER_IDENTITY_PATH: &str = "/v2/quepaxa/recorder/identity";
146
147/// A bounded, clone-shared observer for successful physical SQL write batches.
148///
149/// Installing this observer enables write-phase timing. A runtime without an observer does not
150/// read the clock or synchronize on the profiling path.
151#[cfg(feature = "sql")]
152#[derive(Clone)]
153pub struct SqlWriteProfiler {
154    inner: Arc<SqlWriteProfilerInner>,
155}
156
157#[cfg(feature = "sql")]
158struct SqlWriteProfilerInner {
159    capacity: usize,
160    state: Mutex<SqlWriteProfilerState>,
161}
162
163#[cfg(feature = "sql")]
164#[derive(Default)]
165struct SqlWriteProfilerState {
166    samples: VecDeque<SqlWriteProfileSample>,
167    dropped_samples: u64,
168}
169
170/// Timing for one successfully committed physical SQL QWAL batch.
171#[cfg(feature = "sql")]
172#[derive(Clone, Debug, Eq, PartialEq)]
173pub struct SqlWriteProfileSample {
174    pub batch_member_count: usize,
175    pub commit_lock_wait_us: u64,
176    pub precheck_classification_us: u64,
177    pub qwal_prepare_us: u64,
178    pub consensus_propose_us: u64,
179    pub local_qlog_mirror_append_us: u64,
180    pub sql_materializer_apply_us: u64,
181    pub response_other_total_us: u64,
182    pub total_service_us: u64,
183}
184
185/// A point-in-time copy of a SQL write profiler's bounded samples.
186#[cfg(feature = "sql")]
187#[derive(Clone, Debug, Default, Eq, PartialEq)]
188pub struct SqlWriteProfileSnapshot {
189    pub samples: Vec<SqlWriteProfileSample>,
190    pub dropped_samples: u64,
191}
192
193#[cfg(feature = "sql")]
194impl SqlWriteProfiler {
195    /// Creates an observer that retains at most `capacity` of the newest samples.
196    ///
197    /// # Panics
198    ///
199    /// Panics when `capacity` is zero.
200    pub fn new(capacity: usize) -> Self {
201        assert!(capacity > 0, "SQL write profiler capacity must be non-zero");
202        Self {
203            inner: Arc::new(SqlWriteProfilerInner {
204                capacity,
205                state: Mutex::new(SqlWriteProfilerState::default()),
206            }),
207        }
208    }
209
210    pub fn snapshot(&self) -> SqlWriteProfileSnapshot {
211        let state = self
212            .inner
213            .state
214            .lock()
215            .unwrap_or_else(std::sync::PoisonError::into_inner);
216        SqlWriteProfileSnapshot {
217            samples: state.samples.iter().cloned().collect(),
218            dropped_samples: state.dropped_samples,
219        }
220    }
221
222    /// Returns and clears retained samples while preserving the cumulative dropped count.
223    pub fn drain(&self) -> SqlWriteProfileSnapshot {
224        let mut state = self
225            .inner
226            .state
227            .lock()
228            .unwrap_or_else(std::sync::PoisonError::into_inner);
229        SqlWriteProfileSnapshot {
230            samples: state.samples.drain(..).collect(),
231            dropped_samples: state.dropped_samples,
232        }
233    }
234
235    fn record(&self, sample: SqlWriteProfileSample) {
236        let mut state = self
237            .inner
238            .state
239            .lock()
240            .unwrap_or_else(std::sync::PoisonError::into_inner);
241        if state.samples.len() == self.inner.capacity {
242            state.samples.pop_front();
243            state.dropped_samples = state.dropped_samples.saturating_add(1);
244        }
245        state.samples.push_back(sample);
246    }
247}
248
249#[cfg(feature = "sql")]
250impl fmt::Debug for SqlWriteProfiler {
251    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252        f.debug_struct("SqlWriteProfiler")
253            .field("capacity", &self.inner.capacity)
254            .finish_non_exhaustive()
255    }
256}
257
258#[cfg(feature = "sql")]
259impl PartialEq for SqlWriteProfiler {
260    fn eq(&self, other: &Self) -> bool {
261        Arc::ptr_eq(&self.inner, &other.inner)
262    }
263}
264
265#[cfg(feature = "sql")]
266impl Eq for SqlWriteProfiler {}
267pub const RECORDER_STORE_COMMAND_PATH: &str = "/v2/quepaxa/recorder/store-command";
268pub const RECORDER_FETCH_COMMAND_PATH: &str = "/v2/quepaxa/recorder/fetch-command";
269pub const RECORDER_INSPECT_PROOF_PATH: &str = "/v2/quepaxa/recorder/inspect-proof";
270pub const RECORDER_INSPECT_RECORD_PATH: &str = "/v2/quepaxa/recorder/inspect-record";
271pub const RECORDER_READ_FENCE_PATH: &str = "/v3/quepaxa/recorder/read-fence";
272pub const RECORDER_RECORD_PATH: &str = "/v2/quepaxa/recorder/record";
273pub const RECORDER_INSTALL_PROOF_PATH: &str = "/v2/quepaxa/recorder/install-decision-proof";
274pub const LOG_FETCH_PATH: &str = "/v1/log/fetch";
275#[cfg(feature = "sql")]
276pub const WRITE_PATH: &str = "/v1/write";
277#[cfg(feature = "sql")]
278pub const READ_PATH: &str = "/v1/read";
279#[cfg(feature = "sql")]
280pub const SQL_EXECUTE_PATH: &str = "/v1/sql/execute";
281#[cfg(feature = "sql")]
282pub const SQL_QUERY_PATH: &str = "/v1/sql/query";
283#[cfg(feature = "graph")]
284pub const GRAPH_PUT_DOCUMENT_PATH: &str = "/v1/graph/documents/put";
285#[cfg(feature = "graph")]
286pub const GRAPH_DELETE_DOCUMENT_PATH: &str = "/v1/graph/documents/delete";
287#[cfg(feature = "graph")]
288pub const GRAPH_GET_DOCUMENT_PATH: &str = "/v1/graph/documents/get";
289#[cfg(feature = "graph")]
290pub const GRAPH_QUERY_PATH: &str = "/v1/graph/query";
291#[cfg(feature = "kv")]
292pub const KV_PUT_PATH: &str = "/v1/kv/put";
293#[cfg(feature = "kv")]
294pub const KV_DELETE_PATH: &str = "/v1/kv/delete";
295#[cfg(feature = "kv")]
296pub const KV_GET_PATH: &str = "/v1/kv/get";
297#[cfg(feature = "kv")]
298pub const KV_SCAN_PATH: &str = "/v1/kv/scan";
299pub const LIVEZ_PATH: &str = "/livez";
300pub const READYZ_PATH: &str = "/readyz";
301const MAX_STARTUP_RECOVERY_ENTRIES: usize = 100_000;
302const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
303const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
304const READ_FENCE_REQUEST_TIMEOUT: Duration = Duration::from_secs(1);
305// Leave enough of the public one-second write budget to classify and return a
306// lost-quorum attempt instead of racing the caller's ambiguous timeout.
307const QUORUM_RECORD_REQUEST_TIMEOUT: Duration = Duration::from_millis(250);
308const CLIENT_WRITE_WAIT_TIMEOUT: Duration = Duration::from_secs(1);
309const SYNC_FLUSH_RETRY_INITIAL: Duration = Duration::from_millis(50);
310const SYNC_FLUSH_RETRY_MAX: Duration = Duration::from_secs(1);
311
312fn map_quorum_record_transport_error(error: rhiza_quepaxa::Error) -> rhiza_quepaxa::Error {
313    match error {
314        rhiza_quepaxa::Error::Io(_) | rhiza_quepaxa::Error::Decode(_) => {
315            rhiza_quepaxa::Error::ProposeFailed
316        }
317        error => error,
318    }
319}
320#[cfg(feature = "sql")]
321pub const DEFAULT_SQL_MAX_ROWS: u32 = 1_000;
322#[cfg(feature = "sql")]
323pub const MAX_SQL_MAX_ROWS: u32 = 10_000;
324#[cfg(feature = "sql")]
325pub const MAX_SQL_RESULT_BYTES: usize = 1024 * 1024;
326#[cfg(feature = "sql")]
327pub const MAX_SQL_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
328#[cfg(feature = "kv")]
329type KvMemberCheck = (usize, Result<Option<KvRequestRecord>, NodeError>);
330#[cfg(feature = "kv")]
331pub const DEFAULT_KV_SCAN_LIMIT: u32 = 100;
332#[cfg(feature = "kv")]
333pub const MAX_KV_SCAN_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
334#[cfg(feature = "graph")]
335pub const DEFAULT_GRAPH_MAX_ROWS: u32 = 1_000;
336#[cfg(feature = "graph")]
337pub const MAX_GRAPH_MAX_ROWS: u32 = 10_000;
338#[cfg(feature = "graph")]
339pub const MAX_GRAPH_RESULT_BYTES: usize = 1024 * 1024;
340#[cfg(feature = "graph")]
341pub const MAX_GRAPH_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
342#[cfg(feature = "graph")]
343const GRAPH_QUERY_TIMEOUT_MS: u64 = 5_000;
344
345pub fn effective_cluster_id(
346    profile: ExecutionProfile,
347    logical_cluster_id: &str,
348) -> Result<String, ConfigError> {
349    if let Some(actual) = canonical_cluster_profile(logical_cluster_id) {
350        if actual != profile {
351            return Err(ConfigError::ClusterIdProfileMismatch {
352                expected: profile,
353                actual,
354            });
355        }
356        return Ok(logical_cluster_id.to_owned());
357    }
358    Ok(format!("rhiza:{}:{logical_cluster_id}", profile.as_str()))
359}
360
361pub const fn execution_profile_compiled(profile: ExecutionProfile) -> bool {
362    match profile {
363        ExecutionProfile::Sqlite => cfg!(feature = "sql"),
364        ExecutionProfile::Graph => cfg!(feature = "graph"),
365        ExecutionProfile::Kv => cfg!(feature = "kv"),
366    }
367}
368
369fn canonical_cluster_profile(cluster_id: &str) -> Option<ExecutionProfile> {
370    [
371        ("rhiza:sql:", ExecutionProfile::Sqlite),
372        ("rhiza:graph:", ExecutionProfile::Graph),
373        ("rhiza:kv:", ExecutionProfile::Kv),
374    ]
375    .into_iter()
376    .find_map(|(prefix, profile)| cluster_id.starts_with(prefix).then_some(profile))
377}
378
379#[derive(Clone, Debug, Eq, PartialEq)]
380pub enum ConfigError {
381    EmptyClusterId,
382    ClusterIdProfileMismatch {
383        expected: ExecutionProfile,
384        actual: ExecutionProfile,
385    },
386    EmptyNodeId,
387    EmptyDataDir,
388    InvalidEpoch,
389    InvalidConfigId,
390    InvalidRecoveryGeneration,
391    InvalidWriterBatchMax(usize),
392    InvalidWriterBatchWindow,
393    #[cfg(feature = "sql")]
394    InvalidSqlGroupCommitQueueCapacity(usize),
395    EmptyPeerNodeId,
396    EmptyPeerBaseUrl,
397    InvalidPeerBaseUrl(String),
398    EmptyPeerToken,
399    DuplicatePeerToken,
400    InvalidPeerCount(usize),
401    DuplicatePeerNodeId(String),
402    LocalNodeMissing,
403    PeerMembershipMismatch,
404    InvalidPredecessorTransition(String),
405    EmptyClientToken,
406    ClientTokenConflictsWithPeer,
407    EmptyAdminToken,
408    AdminTokenConflictsWithRuntime,
409    HttpClient(String),
410}
411
412impl fmt::Display for ConfigError {
413    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
414        match self {
415            Self::EmptyClusterId => write!(f, "cluster_id must not be empty"),
416            Self::ClusterIdProfileMismatch { expected, actual } => write!(
417                f,
418                "cluster_id is canonical for the {} profile, not {}",
419                actual.as_str(),
420                expected.as_str()
421            ),
422            Self::EmptyNodeId => write!(f, "node_id must not be empty"),
423            Self::EmptyDataDir => write!(f, "data_dir must not be empty"),
424            Self::InvalidEpoch => write!(f, "epoch must be positive"),
425            Self::InvalidConfigId => write!(f, "config_id must be positive"),
426            Self::InvalidRecoveryGeneration => {
427                write!(f, "recovery_generation must be positive")
428            }
429            Self::InvalidWriterBatchMax(max) => write!(
430                f,
431                "writer batch max must be within 1..={MAX_WRITE_BATCH_MEMBERS}, got {max}"
432            ),
433            Self::InvalidWriterBatchWindow => write!(
434                f,
435                "writer batch window must be positive and shorter than the client deadline"
436            ),
437            #[cfg(feature = "sql")]
438            Self::InvalidSqlGroupCommitQueueCapacity(capacity) => write!(
439                f,
440                "SQL group commit queue capacity must be within 1..={MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY}, got {capacity}"
441            ),
442            Self::EmptyPeerNodeId => write!(f, "peer node_id must not be empty"),
443            Self::EmptyPeerBaseUrl => write!(f, "peer base_url must not be empty"),
444            Self::InvalidPeerBaseUrl(url) => write!(f, "invalid peer base_url: {url}"),
445            Self::EmptyPeerToken => write!(f, "peer token must not be empty"),
446            Self::DuplicatePeerToken => write!(f, "peer tokens must be unique"),
447            Self::InvalidPeerCount(count) => {
448                write!(
449                    f,
450                    "peer membership requires between three and seven nodes, got {count}"
451                )
452            }
453            Self::DuplicatePeerNodeId(node_id) => {
454                write!(f, "peer node_id must be unique: {node_id}")
455            }
456            Self::LocalNodeMissing => write!(f, "peer set must include the local node_id"),
457            Self::PeerMembershipMismatch => {
458                write!(
459                    f,
460                    "peer identities must exactly match the canonical membership"
461                )
462            }
463            Self::InvalidPredecessorTransition(message) => {
464                write!(f, "invalid predecessor transition: {message}")
465            }
466            Self::EmptyClientToken => write!(f, "client token must not be empty"),
467            Self::ClientTokenConflictsWithPeer => {
468                write!(f, "client token must differ from every peer token")
469            }
470            Self::EmptyAdminToken => write!(f, "admin token must not be empty"),
471            Self::AdminTokenConflictsWithRuntime => {
472                write!(f, "admin token must differ from client and peer tokens")
473            }
474            Self::HttpClient(message) => write!(f, "HTTP client configuration failed: {message}"),
475        }
476    }
477}
478
479impl std::error::Error for ConfigError {}
480
481fn validate_recovery_generation(recovery_generation: u64) -> Result<(), ConfigError> {
482    if recovery_generation == 0 {
483        Err(ConfigError::InvalidRecoveryGeneration)
484    } else {
485        Ok(())
486    }
487}
488
489#[derive(Clone, Copy, Debug, Eq, PartialEq)]
490pub enum AckMode {
491    HaFirst,
492    DrStrong,
493}
494
495#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
496#[serde(rename_all = "snake_case")]
497pub enum ReadConsistency {
498    Local,
499    ReadBarrier,
500    AppliedIndex(LogIndex),
501}
502
503#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
504pub struct FetchLogRequest {
505    pub from_index: LogIndex,
506    pub max_entries: u32,
507}
508
509#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
510pub struct FetchLogResponse {
511    pub entries: Vec<LogEntry>,
512    pub last_index: LogIndex,
513}
514
515#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
516pub enum FetchLogError {
517    SnapshotRequired {
518        anchor: Box<RecoveryAnchor>,
519    },
520    Gap {
521        expected: LogIndex,
522        actual: Option<LogIndex>,
523    },
524    Decode {
525        message: String,
526    },
527    Transport {
528        message: String,
529    },
530    InvalidAnchor {
531        expected: LogHash,
532        actual: LogHash,
533    },
534    InvalidEntry {
535        index: LogIndex,
536        message: String,
537    },
538    ForeignIdentity {
539        index: LogIndex,
540    },
541    InvalidRequest {
542        message: String,
543    },
544}
545
546impl fmt::Display for FetchLogError {
547    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
548        match self {
549            Self::SnapshotRequired { anchor } => {
550                write!(
551                    f,
552                    "snapshot restore required at qlog anchor {}",
553                    anchor.compacted().index()
554                )
555            }
556            Self::Gap { expected, actual } => {
557                write!(f, "qlog gap: expected {expected}, got {actual:?}")
558            }
559            Self::Decode { message } => write!(f, "qlog response decode failed: {message}"),
560            Self::Transport { message } => write!(f, "qlog transport failed: {message}"),
561            Self::InvalidAnchor { .. } => write!(f, "qlog response has an invalid anchor"),
562            Self::InvalidEntry { index, message } => {
563                write!(f, "qlog entry {index} is invalid: {message}")
564            }
565            Self::ForeignIdentity { index } => {
566                write!(f, "qlog entry {index} has a foreign identity")
567            }
568            Self::InvalidRequest { message } => write!(f, "invalid qlog request: {message}"),
569        }
570    }
571}
572
573impl std::error::Error for FetchLogError {}
574
575pub trait LogPeer: Send + Sync {
576    /// Fetches one bounded page.
577    ///
578    /// Process-bound implementations must use a finite deadline. HA shutdown
579    /// retains and joins an admitted call even after its deadline is reported,
580    /// so a non-returning custom implementation prevents quiescent shutdown.
581    fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError>;
582}
583
584#[derive(Clone, Debug)]
585pub struct StartupIoContext {
586    cancelled: Arc<AtomicBool>,
587    deadline: Arc<OnceLock<Instant>>,
588    stage: Arc<Mutex<&'static str>>,
589    mutation_admission: Arc<Mutex<()>>,
590}
591
592impl Default for StartupIoContext {
593    fn default() -> Self {
594        Self::new()
595    }
596}
597
598impl StartupIoContext {
599    pub fn new() -> Self {
600        Self {
601            cancelled: Arc::new(AtomicBool::new(false)),
602            deadline: Arc::new(OnceLock::new()),
603            stage: Arc::new(Mutex::new("runtime initialization")),
604            mutation_admission: Arc::new(Mutex::new(())),
605        }
606    }
607
608    pub fn cancel(&self, deadline: Instant) {
609        self.cancelled.store(true, Ordering::Release);
610        let _ = self.deadline.set(deadline);
611    }
612
613    pub fn unfinished_stage(&self) -> &'static str {
614        *self.lock_stage()
615    }
616
617    pub fn check(&self, stage: &'static str) -> Result<(), NodeError> {
618        *self.lock_stage() = stage;
619        if self.cancelled.load(Ordering::Acquire)
620            || self
621                .deadline
622                .get()
623                .is_some_and(|deadline| Instant::now() >= *deadline)
624        {
625            return Err(NodeError::Unavailable(format!(
626                "startup cancelled during {stage}"
627            )));
628        }
629        Ok(())
630    }
631
632    fn persist<T>(
633        &self,
634        stage: &'static str,
635        operation: impl FnOnce() -> Result<T, NodeError>,
636    ) -> Result<T, NodeError> {
637        *self.lock_stage() = stage;
638        let _permit = self
639            .mutation_admission
640            .lock()
641            .unwrap_or_else(std::sync::PoisonError::into_inner);
642        if self.cancelled.load(Ordering::Acquire)
643            || self
644                .deadline
645                .get()
646                .is_some_and(|deadline| Instant::now() >= *deadline)
647        {
648            return Err(NodeError::Unavailable(format!(
649                "startup cancelled before {stage}"
650            )));
651        }
652        operation()
653    }
654
655    fn cancellation_flag(&self) -> &AtomicBool {
656        self.cancelled.as_ref()
657    }
658
659    fn lock_stage(&self) -> std::sync::MutexGuard<'_, &'static str> {
660        self.stage
661            .lock()
662            .unwrap_or_else(std::sync::PoisonError::into_inner)
663    }
664}
665
666#[derive(Clone, Debug, Eq, PartialEq)]
667pub struct InMemoryLogPeer {
668    entries: Vec<LogEntry>,
669    anchor: Option<RecoveryAnchor>,
670}
671
672impl InMemoryLogPeer {
673    pub fn new(mut entries: Vec<LogEntry>) -> Self {
674        entries.sort_by_key(|entry| entry.index);
675        Self {
676            entries,
677            anchor: None,
678        }
679    }
680
681    pub fn with_anchor(mut entries: Vec<LogEntry>, anchor: RecoveryAnchor) -> Self {
682        entries.sort_by_key(|entry| entry.index);
683        Self {
684            entries,
685            anchor: Some(anchor),
686        }
687    }
688}
689
690impl LogPeer for InMemoryLogPeer {
691    fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
692        if let Some(anchor) = &self.anchor {
693            if request.from_index <= anchor.compacted().index() {
694                return Err(FetchLogError::SnapshotRequired {
695                    anchor: Box::new(anchor.clone()),
696                });
697            }
698        }
699        let entries = self
700            .entries
701            .iter()
702            .filter(|entry| entry.index >= request.from_index)
703            .take(request.max_entries as usize)
704            .cloned()
705            .collect();
706        let last_index = self
707            .entries
708            .last()
709            .map(|entry| entry.index)
710            .or_else(|| {
711                self.anchor
712                    .as_ref()
713                    .map(|anchor| anchor.compacted().index())
714            })
715            .unwrap_or(0);
716        Ok(FetchLogResponse {
717            entries,
718            last_index,
719        })
720    }
721}
722
723#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
724struct RecorderWire<T> {
725    version: u16,
726    body: T,
727}
728
729#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
730#[serde(tag = "status", content = "body")]
731enum RecorderV2Result<T> {
732    Ok(T),
733    Rejected(RejectReason),
734    Error(String),
735}
736
737#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
738struct StoreCommandV2 {
739    cluster_id: String,
740    epoch: u64,
741    config_id: u64,
742    config_digest: LogHash,
743    command_hash: LogHash,
744    command: StoredCommand,
745}
746
747#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
748struct FetchCommandV2 {
749    cluster_id: String,
750    epoch: u64,
751    config_id: u64,
752    config_digest: LogHash,
753    command_hash: LogHash,
754}
755
756#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
757struct InspectProofV2 {
758    slot: u64,
759}
760
761#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
762struct InstallProofV2 {
763    proof: DecisionProof,
764    members: Vec<String>,
765}
766
767#[derive(Clone, Debug)]
768pub struct HttpRecorderClient {
769    base_url: String,
770    local_node_id: String,
771    peer_token: String,
772    recovery_generation: u64,
773    client: Arc<std::sync::OnceLock<reqwest::blocking::Client>>,
774}
775
776impl HttpRecorderClient {
777    pub fn new(
778        base_url: impl Into<String>,
779        local_node_id: impl Into<String>,
780        peer_token: impl Into<String>,
781    ) -> Result<Self, ConfigError> {
782        Self::new_with_recovery_generation(base_url, local_node_id, peer_token, 1)
783    }
784
785    pub fn new_with_recovery_generation(
786        base_url: impl Into<String>,
787        local_node_id: impl Into<String>,
788        peer_token: impl Into<String>,
789        recovery_generation: u64,
790    ) -> Result<Self, ConfigError> {
791        validate_recovery_generation(recovery_generation)?;
792        let peer = PeerConfig::new(local_node_id, base_url, peer_token)?;
793        Ok(Self {
794            base_url: peer.base_url,
795            local_node_id: peer.node_id,
796            peer_token: peer.token,
797            recovery_generation,
798            client: Arc::new(std::sync::OnceLock::new()),
799        })
800    }
801
802    pub fn with_recovery_generation(
803        mut self,
804        recovery_generation: u64,
805    ) -> Result<Self, ConfigError> {
806        validate_recovery_generation(recovery_generation)?;
807        self.recovery_generation = recovery_generation;
808        Ok(self)
809    }
810
811    fn url(&self, path: &str) -> String {
812        format!("{}{}", self.base_url, path)
813    }
814
815    fn client(&self) -> rhiza_quepaxa::Result<&reqwest::blocking::Client> {
816        if self.client.get().is_none() {
817            let client = reqwest::blocking::Client::builder()
818                .connect_timeout(HTTP_CONNECT_TIMEOUT)
819                .timeout(HTTP_REQUEST_TIMEOUT)
820                .build()
821                .map_err(|error| rhiza_quepaxa::Error::Io(error.to_string()))?;
822            let _ = self.client.set(client);
823        }
824        self.client
825            .get()
826            .ok_or_else(|| rhiza_quepaxa::Error::Io("HTTP client initialization failed".into()))
827    }
828
829    fn post_v2<T, U>(&self, path: &str, body: T) -> rhiza_quepaxa::Result<U>
830    where
831        T: serde::Serialize,
832        U: serde::de::DeserializeOwned,
833    {
834        self.post_v2_with_timeout(path, body, HTTP_REQUEST_TIMEOUT)
835    }
836
837    fn post_v2_with_timeout<T, U>(
838        &self,
839        path: &str,
840        body: T,
841        timeout: Duration,
842    ) -> rhiza_quepaxa::Result<U>
843    where
844        T: serde::Serialize,
845        U: serde::de::DeserializeOwned,
846    {
847        let response = self
848            .client()?
849            .post(self.url(path))
850            .timeout(timeout)
851            .header(VERSION_HEADER, RECORDER_PROTOCOL_VERSION)
852            .header(NODE_ID_HEADER, &self.local_node_id)
853            .header(
854                RECOVERY_GENERATION_HEADER,
855                self.recovery_generation.to_string(),
856            )
857            .bearer_auth(&self.peer_token)
858            .json(&RecorderWire {
859                version: RECORDER_WIRE_VERSION,
860                body,
861            })
862            .send()
863            .map_err(|error| rhiza_quepaxa::Error::Io(error.to_string()))?;
864        let status = response.status();
865        let wire = response
866            .json::<RecorderWire<RecorderV2Result<U>>>()
867            .map_err(|error| rhiza_quepaxa::Error::Decode(error.to_string()))?;
868        if wire.version != RECORDER_WIRE_VERSION {
869            return Err(rhiza_quepaxa::Error::Decode(
870                "recorder wire version mismatch".into(),
871            ));
872        }
873        match wire.body {
874            RecorderV2Result::Ok(value) if status.is_success() => Ok(value),
875            RecorderV2Result::Ok(_) => Err(rhiza_quepaxa::Error::Io(format!(
876                "recorder rpc returned HTTP {status}"
877            ))),
878            RecorderV2Result::Rejected(reason) => Err(rhiza_quepaxa::Error::Rejected(reason)),
879            RecorderV2Result::Error(message) => Err(rhiza_quepaxa::Error::Io(message)),
880        }
881    }
882}
883
884impl RecorderRpc for HttpRecorderClient {
885    fn recorder_id(&self) -> rhiza_quepaxa::Result<String> {
886        self.post_v2(RECORDER_IDENTITY_PATH, ())
887    }
888
889    fn store_command_for(
890        &self,
891        cluster_id: String,
892        epoch: u64,
893        config_id: u64,
894        config_digest: LogHash,
895        command_hash: LogHash,
896        command: StoredCommand,
897    ) -> rhiza_quepaxa::Result<()> {
898        self.post_v2(
899            RECORDER_STORE_COMMAND_PATH,
900            StoreCommandV2 {
901                cluster_id,
902                epoch,
903                config_id,
904                config_digest,
905                command_hash,
906                command,
907            },
908        )
909    }
910
911    fn fetch_command_for(
912        &self,
913        cluster_id: String,
914        epoch: u64,
915        config_id: u64,
916        config_digest: LogHash,
917        command_hash: LogHash,
918    ) -> rhiza_quepaxa::Result<Option<StoredCommand>> {
919        self.post_v2(
920            RECORDER_FETCH_COMMAND_PATH,
921            FetchCommandV2 {
922                cluster_id,
923                epoch,
924                config_id,
925                config_digest,
926                command_hash,
927            },
928        )
929    }
930
931    fn record(&self, request: RecordRequest) -> rhiza_quepaxa::Result<RecordSummary> {
932        self.post_v2_with_timeout(RECORDER_RECORD_PATH, request, QUORUM_RECORD_REQUEST_TIMEOUT)
933            .map_err(map_quorum_record_transport_error)
934    }
935
936    fn install_decision_proof(
937        &self,
938        proof: DecisionProof,
939        membership: &Membership,
940    ) -> rhiza_quepaxa::Result<()> {
941        self.post_v2(
942            RECORDER_INSTALL_PROOF_PATH,
943            InstallProofV2 {
944                proof,
945                members: membership.members().to_vec(),
946            },
947        )
948    }
949
950    fn inspect_decision_proof(&self, slot: u64) -> rhiza_quepaxa::Result<Option<DecisionProof>> {
951        self.post_v2(RECORDER_INSPECT_PROOF_PATH, InspectProofV2 { slot })
952    }
953
954    fn inspect_record_summary(&self, slot: u64) -> rhiza_quepaxa::Result<Option<RecordSummary>> {
955        self.post_v2(RECORDER_INSPECT_RECORD_PATH, InspectProofV2 { slot })
956    }
957
958    fn supports_context_read_fence(&self) -> bool {
959        true
960    }
961
962    fn observe_read_fence(
963        &self,
964        request: ReadFenceRequest,
965    ) -> rhiza_quepaxa::Result<ReadFenceObservation> {
966        self.post_v2_with_timeout(
967            RECORDER_READ_FENCE_PATH,
968            request,
969            READ_FENCE_REQUEST_TIMEOUT,
970        )
971    }
972}
973
974#[derive(Clone, Debug)]
975pub struct HttpLogPeer {
976    base_url: String,
977    local_node_id: String,
978    peer_token: String,
979    recovery_generation: u64,
980    client: Arc<std::sync::OnceLock<reqwest::blocking::Client>>,
981}
982
983impl HttpLogPeer {
984    pub fn new(
985        base_url: impl Into<String>,
986        local_node_id: impl Into<String>,
987        peer_token: impl Into<String>,
988    ) -> Result<Self, ConfigError> {
989        Self::new_with_recovery_generation(base_url, local_node_id, peer_token, 1)
990    }
991
992    pub fn new_with_recovery_generation(
993        base_url: impl Into<String>,
994        local_node_id: impl Into<String>,
995        peer_token: impl Into<String>,
996        recovery_generation: u64,
997    ) -> Result<Self, ConfigError> {
998        validate_recovery_generation(recovery_generation)?;
999        let peer = PeerConfig::new(local_node_id, base_url, peer_token)?;
1000        Ok(Self {
1001            base_url: peer.base_url,
1002            local_node_id: peer.node_id,
1003            peer_token: peer.token,
1004            recovery_generation,
1005            client: Arc::new(std::sync::OnceLock::new()),
1006        })
1007    }
1008
1009    pub fn with_recovery_generation(
1010        mut self,
1011        recovery_generation: u64,
1012    ) -> Result<Self, ConfigError> {
1013        validate_recovery_generation(recovery_generation)?;
1014        self.recovery_generation = recovery_generation;
1015        Ok(self)
1016    }
1017
1018    fn url(&self, path: &str) -> String {
1019        format!("{}{}", self.base_url, path)
1020    }
1021
1022    fn client(&self) -> Result<&reqwest::blocking::Client, FetchLogError> {
1023        if self.client.get().is_none() {
1024            let client = reqwest::blocking::Client::builder()
1025                .connect_timeout(HTTP_CONNECT_TIMEOUT)
1026                .timeout(HTTP_REQUEST_TIMEOUT)
1027                .build()
1028                .map_err(|error| FetchLogError::Transport {
1029                    message: error.to_string(),
1030                })?;
1031            let _ = self.client.set(client);
1032        }
1033        self.client.get().ok_or_else(|| FetchLogError::Transport {
1034            message: "HTTP client initialization failed".into(),
1035        })
1036    }
1037}
1038
1039impl LogPeer for HttpLogPeer {
1040    fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
1041        let response = self
1042            .client()?
1043            .post(self.url(LOG_FETCH_PATH))
1044            .header(VERSION_HEADER, PROTOCOL_VERSION)
1045            .header(NODE_ID_HEADER, &self.local_node_id)
1046            .header(
1047                RECOVERY_GENERATION_HEADER,
1048                self.recovery_generation.to_string(),
1049            )
1050            .bearer_auth(&self.peer_token)
1051            .json(&request)
1052            .send()
1053            .map_err(|err| FetchLogError::Transport {
1054                message: err.to_string(),
1055            })?;
1056        let status = response.status();
1057        if !status.is_success() {
1058            return match response.json::<FetchLogHttpResponse>() {
1059                Ok(FetchLogHttpResponse::Failed(error)) => Err(error),
1060                Ok(FetchLogHttpResponse::Fetched(_)) | Err(_) => Err(FetchLogError::Transport {
1061                    message: format!("log rpc returned HTTP {status}"),
1062                }),
1063            };
1064        }
1065        match response
1066            .json::<FetchLogHttpResponse>()
1067            .map_err(|err| FetchLogError::Decode {
1068                message: err.to_string(),
1069            })? {
1070            FetchLogHttpResponse::Fetched(response) => Ok(response),
1071            FetchLogHttpResponse::Failed(error) => Err(error),
1072        }
1073    }
1074}
1075
1076#[derive(Clone)]
1077struct RecorderRouteState<R> {
1078    recorder: R,
1079    peers: Vec<PeerConfig>,
1080}
1081
1082#[derive(Clone)]
1083struct AuthenticatedPeer(String);
1084
1085#[derive(Clone)]
1086struct LogRouteState<P> {
1087    peer: P,
1088}
1089
1090#[derive(Clone)]
1091struct NodeRouteState {
1092    runtime: Arc<NodeRuntime>,
1093    coordinator: Option<Arc<CheckpointCoordinator>>,
1094    write_operations: Arc<tokio::sync::Mutex<HashMap<String, WriteOperation>>>,
1095    writer: tokio::sync::mpsc::Sender<QueuedWrite>,
1096}
1097
1098#[derive(Clone)]
1099struct WriteOperation {
1100    payload: Vec<u8>,
1101    result: tokio::sync::watch::Receiver<Option<WriteOperationResult>>,
1102}
1103
1104#[derive(Clone)]
1105enum WriteOperationResult {
1106    Runtime(Result<ClientWriteResponse, NodeError>),
1107    DurabilityUnavailable,
1108}
1109
1110#[derive(Clone)]
1111enum ClientWriteResponse {
1112    #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1113    Unavailable,
1114    #[cfg(feature = "sql")]
1115    KeyValue(WriteResponse),
1116    #[cfg(feature = "sql")]
1117    Sql(SqlExecuteResponse),
1118    #[cfg(feature = "graph")]
1119    Graph(GraphMutationOutcome),
1120    #[cfg(feature = "kv")]
1121    Kv(KvMutationOutcome),
1122}
1123
1124struct QueuedWrite {
1125    request_id: String,
1126    payload: Vec<u8>,
1127    operation: QueuedOperation,
1128    permit: Arc<tokio::sync::OwnedSemaphorePermit>,
1129    sender: tokio::sync::watch::Sender<Option<WriteOperationResult>>,
1130}
1131
1132enum QueuedOperation {
1133    #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1134    Unavailable,
1135    #[cfg(feature = "sql")]
1136    KeyValue { key: String, value: String },
1137    #[cfg(feature = "sql")]
1138    Sql(SqlCommand),
1139    #[cfg(feature = "graph")]
1140    Graph(GraphCommandV1),
1141    #[cfg(feature = "kv")]
1142    Kv(KvCommandV1),
1143}
1144
1145struct RuntimeBatchMember {
1146    #[cfg(feature = "sql")]
1147    request_id: String,
1148    payload: Vec<u8>,
1149    operation: QueuedOperation,
1150}
1151
1152#[cfg(feature = "sql")]
1153type SqlGroupCommitResult = Result<Vec<Result<ClientWriteResponse, NodeError>>, NodeError>;
1154
1155#[cfg(feature = "sql")]
1156struct SqlGroupCommitJob {
1157    member_count: usize,
1158    encoded_bytes: usize,
1159    members: Mutex<Option<Vec<RuntimeBatchMember>>>,
1160    result: Mutex<Option<SqlGroupCommitResult>>,
1161    changed: Condvar,
1162}
1163
1164#[cfg(feature = "sql")]
1165impl SqlGroupCommitJob {
1166    fn new(members: Vec<RuntimeBatchMember>) -> Self {
1167        Self {
1168            member_count: members.len(),
1169            encoded_bytes: members.iter().fold(0_usize, |bytes, member| {
1170                bytes.saturating_add(member.payload.len())
1171            }),
1172            members: Mutex::new(Some(members)),
1173            result: Mutex::new(None),
1174            changed: Condvar::new(),
1175        }
1176    }
1177
1178    fn take_members(&self) -> Result<Vec<RuntimeBatchMember>, NodeError> {
1179        self.members
1180            .lock()
1181            .unwrap_or_else(std::sync::PoisonError::into_inner)
1182            .take()
1183            .ok_or_else(|| {
1184                NodeError::Invariant("SQL group commit job members were already consumed".into())
1185            })
1186    }
1187
1188    fn publish(&self, result: SqlGroupCommitResult) {
1189        let mut slot = self
1190            .result
1191            .lock()
1192            .unwrap_or_else(std::sync::PoisonError::into_inner);
1193        if slot.is_none() {
1194            *slot = Some(result);
1195            self.changed.notify_all();
1196        }
1197    }
1198
1199    fn wait(&self, cancelled: &AtomicBool) -> SqlGroupCommitResult {
1200        let mut result = self
1201            .result
1202            .lock()
1203            .unwrap_or_else(std::sync::PoisonError::into_inner);
1204        loop {
1205            if let Some(result) = result.take() {
1206                return result;
1207            }
1208            if cancelled.load(Ordering::Acquire) {
1209                return Err(NodeError::Unavailable(
1210                    "SQL group commit cancelled during shutdown".into(),
1211                ));
1212            }
1213            result = self
1214                .changed
1215                .wait(result)
1216                .unwrap_or_else(std::sync::PoisonError::into_inner);
1217        }
1218    }
1219}
1220
1221#[cfg(feature = "sql")]
1222struct SqlGroupCommitQueue {
1223    capacity: usize,
1224    state: Mutex<SqlGroupCommitQueueState>,
1225    changed: Condvar,
1226}
1227
1228#[cfg(feature = "sql")]
1229#[derive(Default)]
1230struct SqlGroupCommitQueueState {
1231    pending: VecDeque<Arc<SqlGroupCommitJob>>,
1232    pending_encoded_bytes: usize,
1233    leader_active: bool,
1234}
1235
1236#[cfg(feature = "sql")]
1237impl SqlGroupCommitQueue {
1238    fn new(capacity: usize) -> Self {
1239        Self {
1240            capacity,
1241            state: Mutex::new(SqlGroupCommitQueueState::default()),
1242            changed: Condvar::new(),
1243        }
1244    }
1245
1246    fn enqueue(
1247        &self,
1248        members: Vec<RuntimeBatchMember>,
1249        cancelled: &AtomicBool,
1250    ) -> Result<(Arc<SqlGroupCommitJob>, bool), NodeError> {
1251        if cancelled.load(Ordering::Acquire) {
1252            return Err(NodeError::Unavailable(
1253                "SQL group commit is unavailable during shutdown".into(),
1254            ));
1255        }
1256        let job = Arc::new(SqlGroupCommitJob::new(members));
1257        if job.encoded_bytes > MAX_COMMAND_BYTES {
1258            return Err(NodeError::ResourceExhausted(format!(
1259                "SQL group commit call exceeds {MAX_COMMAND_BYTES} encoded bytes"
1260            )));
1261        }
1262        let mut state = self
1263            .state
1264            .lock()
1265            .unwrap_or_else(std::sync::PoisonError::into_inner);
1266        if state.pending.len() >= self.capacity {
1267            return Err(NodeError::ResourceExhausted(format!(
1268                "SQL group commit queue is full (capacity {})",
1269                self.capacity
1270            )));
1271        }
1272        let pending_encoded_bytes = state
1273            .pending_encoded_bytes
1274            .saturating_add(job.encoded_bytes);
1275        if pending_encoded_bytes > MAX_SQL_GROUP_COMMIT_PENDING_BYTES {
1276            return Err(NodeError::ResourceExhausted(format!(
1277                "SQL group commit queue exceeds {MAX_SQL_GROUP_COMMIT_PENDING_BYTES} pending encoded bytes"
1278            )));
1279        }
1280        state.pending_encoded_bytes = pending_encoded_bytes;
1281        state.pending.push_back(Arc::clone(&job));
1282        self.changed.notify_all();
1283        let leader = !state.leader_active;
1284        if leader {
1285            state.leader_active = true;
1286        }
1287        Ok((job, leader))
1288    }
1289
1290    fn drain_next_group(&self) -> Option<Vec<Arc<SqlGroupCommitJob>>> {
1291        let mut state = self
1292            .state
1293            .lock()
1294            .unwrap_or_else(std::sync::PoisonError::into_inner);
1295        if state.pending.is_empty() {
1296            state.leader_active = false;
1297            return None;
1298        }
1299        let mut member_count = 0_usize;
1300        let mut encoded_bytes = 0_usize;
1301        let mut jobs = Vec::new();
1302        while let Some(job) = state.pending.front() {
1303            let next_count = member_count.saturating_add(job.member_count);
1304            let next_encoded_bytes = encoded_bytes.saturating_add(job.encoded_bytes);
1305            if !jobs.is_empty()
1306                && (next_count > MAX_SQL_WRITE_BATCH_MEMBERS
1307                    || next_encoded_bytes > MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES)
1308            {
1309                break;
1310            }
1311            let job = state.pending.pop_front().expect("front job exists");
1312            member_count = next_count;
1313            encoded_bytes = next_encoded_bytes;
1314            state.pending_encoded_bytes = state
1315                .pending_encoded_bytes
1316                .checked_sub(job.encoded_bytes)
1317                .expect("queued SQL byte reservation covers every pending job");
1318            jobs.push(job);
1319        }
1320        Some(jobs)
1321    }
1322
1323    fn collect_until_full_or_timeout(&self, timeout: Duration) -> bool {
1324        let mut state = self
1325            .state
1326            .lock()
1327            .unwrap_or_else(std::sync::PoisonError::into_inner);
1328        if state.pending.is_empty() {
1329            state.leader_active = false;
1330            return false;
1331        }
1332        let hard_deadline = Instant::now() + timeout.saturating_mul(4);
1333        loop {
1334            let member_count = state
1335                .pending
1336                .iter()
1337                .fold(0_usize, |count, job| count.saturating_add(job.member_count));
1338            if member_count >= MAX_SQL_WRITE_BATCH_MEMBERS
1339                || state.pending_encoded_bytes >= MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES
1340            {
1341                return true;
1342            }
1343            let remaining = hard_deadline.saturating_duration_since(Instant::now());
1344            if remaining.is_zero() {
1345                return true;
1346            }
1347            let observed_calls = state.pending.len();
1348            let (next, quiet) = self
1349                .changed
1350                .wait_timeout_while(state, timeout.min(remaining), |state| {
1351                    state.pending.len() == observed_calls
1352                        && state
1353                            .pending
1354                            .iter()
1355                            .fold(0_usize, |count, job| count.saturating_add(job.member_count))
1356                            < MAX_SQL_WRITE_BATCH_MEMBERS
1357                        && state.pending_encoded_bytes < MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES
1358                })
1359                .unwrap_or_else(std::sync::PoisonError::into_inner);
1360            state = next;
1361            if quiet.timed_out() {
1362                return true;
1363            }
1364        }
1365    }
1366
1367    fn fail_pending(&self, error: NodeError) {
1368        let jobs = {
1369            let mut state = self
1370                .state
1371                .lock()
1372                .unwrap_or_else(std::sync::PoisonError::into_inner);
1373            state.leader_active = false;
1374            state.pending_encoded_bytes = 0;
1375            state.pending.drain(..).collect::<Vec<_>>()
1376        };
1377        for job in jobs {
1378            job.publish(Err(error.clone()));
1379        }
1380    }
1381
1382    #[cfg(test)]
1383    fn wait_for_pending_calls(&self, expected: usize, timeout: Duration) {
1384        let state = self
1385            .state
1386            .lock()
1387            .unwrap_or_else(std::sync::PoisonError::into_inner);
1388        let (state, timed_out) = self
1389            .changed
1390            .wait_timeout_while(state, timeout, |state| state.pending.len() != expected)
1391            .unwrap_or_else(std::sync::PoisonError::into_inner);
1392        assert!(
1393            !timed_out.timed_out(),
1394            "expected {expected} pending SQL group commit calls, got {}",
1395            state.pending.len()
1396        );
1397    }
1398}
1399
1400#[cfg(feature = "kv")]
1401type KvGroupCommitResult = Result<Vec<Result<ClientWriteResponse, NodeError>>, NodeError>;
1402
1403#[cfg(feature = "kv")]
1404struct KvGroupCommitJob {
1405    member_count: usize,
1406    encoded_bytes: usize,
1407    members: Mutex<Option<Vec<RuntimeBatchMember>>>,
1408    result: Mutex<Option<KvGroupCommitResult>>,
1409    changed: Condvar,
1410}
1411
1412#[cfg(feature = "kv")]
1413impl KvGroupCommitJob {
1414    fn new(members: Vec<RuntimeBatchMember>) -> Self {
1415        Self {
1416            member_count: members.len(),
1417            encoded_bytes: members.iter().fold(0_usize, |bytes, member| {
1418                bytes.saturating_add(member.payload.len())
1419            }),
1420            members: Mutex::new(Some(members)),
1421            result: Mutex::new(None),
1422            changed: Condvar::new(),
1423        }
1424    }
1425
1426    fn take_members(&self) -> Result<Vec<RuntimeBatchMember>, NodeError> {
1427        self.members
1428            .lock()
1429            .unwrap_or_else(std::sync::PoisonError::into_inner)
1430            .take()
1431            .ok_or_else(|| {
1432                NodeError::Invariant("KV group commit job members were already consumed".into())
1433            })
1434    }
1435
1436    fn publish(&self, result: KvGroupCommitResult) {
1437        let mut slot = self
1438            .result
1439            .lock()
1440            .unwrap_or_else(std::sync::PoisonError::into_inner);
1441        if slot.is_none() {
1442            *slot = Some(result);
1443            self.changed.notify_all();
1444        }
1445    }
1446
1447    fn wait(&self, cancelled: &AtomicBool) -> KvGroupCommitResult {
1448        let mut result = self
1449            .result
1450            .lock()
1451            .unwrap_or_else(std::sync::PoisonError::into_inner);
1452        loop {
1453            if let Some(result) = result.take() {
1454                return result;
1455            }
1456            if cancelled.load(Ordering::Acquire) {
1457                return Err(NodeError::Unavailable(
1458                    "KV group commit cancelled during shutdown".into(),
1459                ));
1460            }
1461            result = self
1462                .changed
1463                .wait(result)
1464                .unwrap_or_else(std::sync::PoisonError::into_inner);
1465        }
1466    }
1467}
1468
1469#[cfg(feature = "kv")]
1470struct KvGroupCommitQueue {
1471    state: Mutex<KvGroupCommitQueueState>,
1472    changed: Condvar,
1473}
1474
1475#[cfg(feature = "kv")]
1476#[derive(Default)]
1477struct KvGroupCommitQueueState {
1478    pending: VecDeque<Arc<KvGroupCommitJob>>,
1479    pending_encoded_bytes: usize,
1480    leader_active: bool,
1481}
1482
1483#[cfg(feature = "kv")]
1484impl KvGroupCommitQueue {
1485    fn new() -> Self {
1486        Self {
1487            state: Mutex::new(KvGroupCommitQueueState::default()),
1488            changed: Condvar::new(),
1489        }
1490    }
1491
1492    fn enqueue(
1493        &self,
1494        members: Vec<RuntimeBatchMember>,
1495        cancelled: &AtomicBool,
1496    ) -> Result<(Arc<KvGroupCommitJob>, bool), NodeError> {
1497        if cancelled.load(Ordering::Acquire) {
1498            return Err(NodeError::Unavailable(
1499                "KV group commit is unavailable during shutdown".into(),
1500            ));
1501        }
1502        let job = Arc::new(KvGroupCommitJob::new(members));
1503        if job.member_count == 0 || job.member_count > MAX_KV_BATCH_MEMBERS {
1504            return Err(NodeError::InvalidRequest(format!(
1505                "KV group commit call must contain 1..={MAX_KV_BATCH_MEMBERS} members"
1506            )));
1507        }
1508        if job.encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES {
1509            return Err(NodeError::ResourceExhausted(format!(
1510                "KV group commit call exceeds {MAX_KV_GROUP_COMMIT_PENDING_BYTES} encoded bytes"
1511            )));
1512        }
1513        let mut state = self
1514            .state
1515            .lock()
1516            .unwrap_or_else(std::sync::PoisonError::into_inner);
1517        if state.pending.len() >= KV_GROUP_COMMIT_QUEUE_CAPACITY {
1518            return Err(NodeError::ResourceExhausted(format!(
1519                "KV group commit queue is full (capacity {KV_GROUP_COMMIT_QUEUE_CAPACITY})"
1520            )));
1521        }
1522        let pending_encoded_bytes = state
1523            .pending_encoded_bytes
1524            .saturating_add(job.encoded_bytes);
1525        if pending_encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES {
1526            return Err(NodeError::ResourceExhausted(format!(
1527                "KV group commit queue exceeds {MAX_KV_GROUP_COMMIT_PENDING_BYTES} pending encoded bytes"
1528            )));
1529        }
1530        state.pending_encoded_bytes = pending_encoded_bytes;
1531        state.pending.push_back(Arc::clone(&job));
1532        self.changed.notify_all();
1533        let leader = !state.leader_active;
1534        if leader {
1535            state.leader_active = true;
1536        }
1537        Ok((job, leader))
1538    }
1539
1540    fn drain_next_group(&self) -> Option<Vec<Arc<KvGroupCommitJob>>> {
1541        let mut state = self
1542            .state
1543            .lock()
1544            .unwrap_or_else(std::sync::PoisonError::into_inner);
1545        if state.pending.is_empty() {
1546            state.leader_active = false;
1547            return None;
1548        }
1549        let mut member_count = 0_usize;
1550        let mut encoded_bytes = 0_usize;
1551        let mut jobs = Vec::new();
1552        while let Some(job) = state.pending.front() {
1553            let next_count = member_count.saturating_add(job.member_count);
1554            let next_encoded_bytes = encoded_bytes.saturating_add(job.encoded_bytes);
1555            if !jobs.is_empty()
1556                && (next_count > MAX_KV_GROUP_COMMIT_MEMBERS
1557                    || next_encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES)
1558            {
1559                break;
1560            }
1561            let job = state.pending.pop_front().expect("front job exists");
1562            member_count = next_count;
1563            encoded_bytes = next_encoded_bytes;
1564            state.pending_encoded_bytes = state
1565                .pending_encoded_bytes
1566                .checked_sub(job.encoded_bytes)
1567                .expect("queued KV byte reservation covers every pending job");
1568            jobs.push(job);
1569        }
1570        Some(jobs)
1571    }
1572
1573    fn collect_until_full_or_timeout(&self, timeout: Duration) -> bool {
1574        let mut state = self
1575            .state
1576            .lock()
1577            .unwrap_or_else(std::sync::PoisonError::into_inner);
1578        if state.pending.is_empty() {
1579            state.leader_active = false;
1580            return false;
1581        }
1582        let hard_deadline = Instant::now() + timeout.saturating_mul(4);
1583        loop {
1584            let member_count = state
1585                .pending
1586                .iter()
1587                .fold(0_usize, |count, job| count.saturating_add(job.member_count));
1588            if member_count >= MAX_KV_GROUP_COMMIT_MEMBERS
1589                || state.pending_encoded_bytes >= MAX_KV_GROUP_COMMIT_PENDING_BYTES
1590            {
1591                return true;
1592            }
1593            let remaining = hard_deadline.saturating_duration_since(Instant::now());
1594            if remaining.is_zero() {
1595                return true;
1596            }
1597            let observed_calls = state.pending.len();
1598            let (next, quiet) = self
1599                .changed
1600                .wait_timeout_while(state, timeout.min(remaining), |state| {
1601                    state.pending.len() == observed_calls
1602                        && state
1603                            .pending
1604                            .iter()
1605                            .fold(0_usize, |count, job| count.saturating_add(job.member_count))
1606                            < MAX_KV_GROUP_COMMIT_MEMBERS
1607                        && state.pending_encoded_bytes < MAX_KV_GROUP_COMMIT_PENDING_BYTES
1608                })
1609                .unwrap_or_else(std::sync::PoisonError::into_inner);
1610            state = next;
1611            if quiet.timed_out() {
1612                return true;
1613            }
1614        }
1615    }
1616
1617    fn fail_pending(&self, error: NodeError) {
1618        let jobs = {
1619            let mut state = self
1620                .state
1621                .lock()
1622                .unwrap_or_else(std::sync::PoisonError::into_inner);
1623            state.leader_active = false;
1624            state.pending_encoded_bytes = 0;
1625            state.pending.drain(..).collect::<Vec<_>>()
1626        };
1627        for job in jobs {
1628            job.publish(Err(error.clone()));
1629        }
1630    }
1631
1632    #[cfg(test)]
1633    fn wait_for_pending_calls(&self, expected: usize, timeout: Duration) {
1634        let state = self
1635            .state
1636            .lock()
1637            .unwrap_or_else(std::sync::PoisonError::into_inner);
1638        let (state, timed_out) = self
1639            .changed
1640            .wait_timeout_while(state, timeout, |state| state.pending.len() != expected)
1641            .unwrap_or_else(std::sync::PoisonError::into_inner);
1642        assert!(
1643            !timed_out.timed_out(),
1644            "expected {expected} pending KV group commit calls, got {}",
1645            state.pending.len()
1646        );
1647    }
1648}
1649
1650#[cfg(any(feature = "graph", feature = "kv"))]
1651fn classify_pending_request(
1652    canonical_by_request: &mut HashMap<String, usize>,
1653    members: &[RuntimeBatchMember],
1654    index: usize,
1655    request_id: &str,
1656) -> Result<Option<usize>, NodeError> {
1657    let Some(&canonical) = canonical_by_request.get(request_id) else {
1658        canonical_by_request.insert(request_id.to_owned(), index);
1659        return Ok(None);
1660    };
1661    if members[canonical].payload == members[index].payload {
1662        Ok(Some(canonical))
1663    } else {
1664        Err(NodeError::InvalidRequest(format!(
1665            "request id {request_id:?} was reused with another command in the same writer batch"
1666        )))
1667    }
1668}
1669
1670impl ClientWriteResponse {
1671    fn applied_index(&self) -> LogIndex {
1672        match self {
1673            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1674            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
1675            #[cfg(feature = "sql")]
1676            Self::KeyValue(response) => response.applied_index,
1677            #[cfg(feature = "sql")]
1678            Self::Sql(response) => response.applied_index,
1679            #[cfg(feature = "graph")]
1680            Self::Graph(response) => response.applied_index(),
1681            #[cfg(feature = "kv")]
1682            Self::Kv(response) => response.applied_index(),
1683        }
1684    }
1685}
1686
1687#[cfg(feature = "sql")]
1688#[derive(Clone)]
1689pub struct NodeService {
1690    runtime: Arc<NodeRuntime>,
1691    coordinator: Option<Arc<CheckpointCoordinator>>,
1692    #[cfg(feature = "sql")]
1693    sql_reads_in_flight: Arc<AtomicUsize>,
1694}
1695
1696#[cfg(feature = "sql")]
1697struct SqlReadActivity {
1698    active: Arc<AtomicUsize>,
1699}
1700
1701#[cfg(feature = "sql")]
1702impl SqlReadActivity {
1703    fn enter(active: &Arc<AtomicUsize>) -> (Self, bool) {
1704        let previous = active.fetch_add(1, Ordering::Relaxed);
1705        debug_assert_ne!(previous, usize::MAX);
1706        (
1707            Self {
1708                active: active.clone(),
1709            },
1710            previous == 0,
1711        )
1712    }
1713}
1714
1715#[cfg(feature = "sql")]
1716impl Drop for SqlReadActivity {
1717    fn drop(&mut self) {
1718        let previous = self.active.fetch_sub(1, Ordering::Relaxed);
1719        debug_assert!(previous > 0);
1720    }
1721}
1722
1723#[cfg(feature = "sql")]
1724impl NodeService {
1725    pub fn new(runtime: Arc<NodeRuntime>, coordinator: Option<Arc<CheckpointCoordinator>>) -> Self {
1726        Self {
1727            runtime,
1728            coordinator,
1729            #[cfg(feature = "sql")]
1730            sql_reads_in_flight: Arc::new(AtomicUsize::new(0)),
1731        }
1732    }
1733
1734    #[cfg(feature = "sql")]
1735    pub async fn put(
1736        &self,
1737        request_id: &str,
1738        key: &str,
1739        value: &str,
1740    ) -> Result<WriteResponse, NodeError> {
1741        self.write(WriteRequest {
1742            request_id: request_id.into(),
1743            key: key.into(),
1744            value: value.into(),
1745        })
1746        .await
1747    }
1748
1749    #[cfg(feature = "sql")]
1750    pub async fn write(&self, request: WriteRequest) -> Result<WriteResponse, NodeError> {
1751        self.write_allowed()?;
1752        let runtime = self.runtime.clone();
1753        let response = tokio::task::spawn_blocking(move || {
1754            runtime.write(&request.request_id, &request.key, &request.value)
1755        })
1756        .await
1757        .map_err(node_service_task_error)??;
1758        self.confirm_committed(response.applied_index).await?;
1759        Ok(response)
1760    }
1761
1762    #[cfg(feature = "sql")]
1763    pub async fn execute_sql(&self, command: SqlCommand) -> Result<SqlExecuteResponse, NodeError> {
1764        self.write_allowed()?;
1765        let runtime = self.runtime.clone();
1766        let response =
1767            tokio::task::spawn_blocking(move || runtime.execute_sql_with_results(command))
1768                .await
1769                .map_err(node_service_task_error)??;
1770        self.confirm_committed(response.applied_index).await?;
1771        Ok(response)
1772    }
1773
1774    #[cfg(feature = "sql")]
1775    pub async fn read(
1776        &self,
1777        key: &str,
1778        consistency: ReadConsistency,
1779    ) -> Result<ReadResponse, NodeError> {
1780        let runtime = self.runtime.clone();
1781        let key = key.to_owned();
1782        self.run_sql_read_operation(consistency, move || runtime.read(&key, consistency))
1783            .await
1784            .map_err(node_service_task_error)?
1785    }
1786
1787    #[cfg(feature = "sql")]
1788    pub async fn query(
1789        &self,
1790        statement: SqlStatement,
1791        consistency: ReadConsistency,
1792        max_rows: u32,
1793    ) -> Result<SqlQueryResponse, NodeError> {
1794        let runtime = self.runtime.clone();
1795        self.run_sql_read_operation(consistency, move || {
1796            runtime.query_sql(&statement, consistency, max_rows)
1797        })
1798        .await
1799        .map_err(node_service_task_error)?
1800    }
1801
1802    #[cfg(feature = "sql")]
1803    async fn run_sql_read_operation<F, T>(
1804        &self,
1805        consistency: ReadConsistency,
1806        operation: F,
1807    ) -> Result<T, tokio::task::JoinError>
1808    where
1809        F: FnOnce() -> T + Send + 'static,
1810        T: Send + 'static,
1811    {
1812        if consistency == ReadConsistency::ReadBarrier {
1813            return run_read_operation(consistency, operation).await;
1814        }
1815        let (activity, sole_read) = SqlReadActivity::enter(&self.sql_reads_in_flight);
1816        let operation = move || {
1817            let _activity = activity;
1818            operation()
1819        };
1820        if sole_read {
1821            run_read_operation(consistency, operation).await
1822        } else {
1823            tokio::task::spawn_blocking(operation).await
1824        }
1825    }
1826
1827    #[cfg(feature = "sql")]
1828    fn write_allowed(&self) -> Result<(), NodeError> {
1829        self.coordinator
1830            .as_ref()
1831            .map_or(Ok(()), |coordinator| coordinator.write_allowed())
1832            .map_err(|error| NodeError::Unavailable(error.to_string()))
1833    }
1834
1835    #[cfg(feature = "sql")]
1836    async fn confirm_committed(&self, index: LogIndex) -> Result<(), NodeError> {
1837        confirm_write_durability(self.runtime.as_ref(), self.coordinator.as_deref(), index)
1838            .await
1839            .map_err(|error| NodeError::Unavailable(error.to_string()))
1840    }
1841}
1842
1843#[cfg(feature = "sql")]
1844fn node_service_task_error(error: tokio::task::JoinError) -> NodeError {
1845    NodeError::Fatal(format!("node service task failed: {error}"))
1846}
1847
1848#[doc(hidden)]
1849pub async fn run_read_operation<F, T>(
1850    consistency: ReadConsistency,
1851    operation: F,
1852) -> Result<T, tokio::task::JoinError>
1853where
1854    F: FnOnce() -> T + Send + 'static,
1855    T: Send + 'static,
1856{
1857    if consistency != ReadConsistency::ReadBarrier
1858        && matches!(
1859            tokio::runtime::Handle::current().runtime_flavor(),
1860            tokio::runtime::RuntimeFlavor::MultiThread
1861        )
1862    {
1863        Ok(tokio::task::block_in_place(operation))
1864    } else {
1865        tokio::task::spawn_blocking(operation).await
1866    }
1867}
1868
1869#[derive(Clone)]
1870struct PeerGateState {
1871    peers: Vec<PeerConfig>,
1872    recovery_generation: u64,
1873    protocol_version: &'static str,
1874    slots: Arc<tokio::sync::Semaphore>,
1875}
1876
1877#[derive(Clone)]
1878struct ClientGateState {
1879    runtime: Arc<NodeRuntime>,
1880    slots: Arc<tokio::sync::Semaphore>,
1881    coordinator: Option<Arc<CheckpointCoordinator>>,
1882}
1883
1884#[derive(Clone)]
1885struct RuntimeLogPeer {
1886    runtime: Arc<NodeRuntime>,
1887}
1888
1889impl LogPeer for RuntimeLogPeer {
1890    fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
1891        self.runtime.fetch_log(request)
1892    }
1893}
1894
1895fn fetch_runtime_log(
1896    runtime: &NodeRuntime,
1897    request: FetchLogRequest,
1898) -> Result<FetchLogResponse, FetchLogError> {
1899    if request.from_index == 0 || request.max_entries > MAX_FETCH_ENTRIES {
1900        return Err(FetchLogError::InvalidRequest {
1901            message: "invalid fetch bounds".into(),
1902        });
1903    }
1904    let state = runtime
1905        .log_store
1906        .logical_state()
1907        .map_err(|error| FetchLogError::Transport {
1908            message: error.to_string(),
1909        })?;
1910    if let Some(anchor) = state.anchor {
1911        if request.from_index <= anchor.compacted().index() {
1912            return Err(FetchLogError::SnapshotRequired {
1913                anchor: Box::new(anchor),
1914            });
1915        }
1916    }
1917    let last_index = state.tip.map_or(0, |tip| tip.index());
1918    if request.max_entries == 0 || request.from_index > last_index {
1919        return Ok(FetchLogResponse {
1920            entries: Vec::new(),
1921            last_index,
1922        });
1923    }
1924    let end = request
1925        .from_index
1926        .saturating_add(u64::from(request.max_entries) - 1)
1927        .min(last_index);
1928    let range = IndexRange::new(request.from_index, end).map_err(|error| {
1929        FetchLogError::InvalidRequest {
1930            message: error.to_string(),
1931        }
1932    })?;
1933    let entries =
1934        runtime
1935            .log_store
1936            .read_range(range)
1937            .map_err(|error| FetchLogError::Transport {
1938                message: error.to_string(),
1939            })?;
1940    Ok(FetchLogResponse {
1941        entries,
1942        last_index,
1943    })
1944}
1945
1946pub fn recorder_router<R, P>(recorder: R, peers: P) -> Router
1947where
1948    R: RecorderRpc + Clone + Send + Sync + 'static,
1949    P: Into<Vec<PeerConfig>>,
1950{
1951    recorder_router_for_generation(recorder, peers, 1)
1952}
1953
1954pub fn recorder_router_for_generation<R, P>(
1955    recorder: R,
1956    peers: P,
1957    recovery_generation: u64,
1958) -> Router
1959where
1960    R: RecorderRpc + Clone + Send + Sync + 'static,
1961    P: Into<Vec<PeerConfig>>,
1962{
1963    recorder_routes(
1964        recorder,
1965        peers.into(),
1966        recovery_generation,
1967        Arc::new(tokio::sync::Semaphore::new(DEFAULT_PEER_CONCURRENCY)),
1968    )
1969    .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
1970}
1971
1972pub fn log_peer_router<P, C>(peer: P, peers: C) -> Router
1973where
1974    P: LogPeer + Clone + Send + Sync + 'static,
1975    C: Into<Vec<PeerConfig>>,
1976{
1977    log_peer_router_for_generation(peer, peers, 1)
1978}
1979
1980pub fn log_peer_router_for_generation<P, C>(peer: P, peers: C, recovery_generation: u64) -> Router
1981where
1982    P: LogPeer + Clone + Send + Sync + 'static,
1983    C: Into<Vec<PeerConfig>>,
1984{
1985    log_routes(
1986        peer,
1987        peers.into(),
1988        recovery_generation,
1989        Arc::new(tokio::sync::Semaphore::new(DEFAULT_PEER_CONCURRENCY)),
1990    )
1991    .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
1992}
1993
1994pub fn node_rpc_router<R, P, C>(recorder: R, peer: P, peers: C) -> Router
1995where
1996    R: RecorderRpc + Clone + Send + Sync + 'static,
1997    P: LogPeer + Clone + Send + Sync + 'static,
1998    C: Into<Vec<PeerConfig>>,
1999{
2000    node_rpc_router_for_generation(recorder, peer, peers, 1)
2001}
2002
2003pub fn node_rpc_router_for_generation<R, P, C>(
2004    recorder: R,
2005    peer: P,
2006    peers: C,
2007    recovery_generation: u64,
2008) -> Router
2009where
2010    R: RecorderRpc + Clone + Send + Sync + 'static,
2011    P: LogPeer + Clone + Send + Sync + 'static,
2012    C: Into<Vec<PeerConfig>>,
2013{
2014    node_rpc_router_with_limits_for_generation(
2015        recorder,
2016        peer,
2017        peers,
2018        DEFAULT_PEER_CONCURRENCY,
2019        DEFAULT_PEER_CONCURRENCY,
2020        recovery_generation,
2021    )
2022}
2023
2024pub fn node_rpc_router_with_limits<R, P, C>(
2025    recorder: R,
2026    peer: P,
2027    peers: C,
2028    recorder_concurrency: usize,
2029    log_concurrency: usize,
2030) -> Router
2031where
2032    R: RecorderRpc + Clone + Send + Sync + 'static,
2033    P: LogPeer + Clone + Send + Sync + 'static,
2034    C: Into<Vec<PeerConfig>>,
2035{
2036    node_rpc_router_with_limits_for_generation(
2037        recorder,
2038        peer,
2039        peers,
2040        recorder_concurrency,
2041        log_concurrency,
2042        1,
2043    )
2044}
2045
2046pub fn node_rpc_router_with_limits_for_generation<R, P, C>(
2047    recorder: R,
2048    peer: P,
2049    peers: C,
2050    recorder_concurrency: usize,
2051    log_concurrency: usize,
2052    recovery_generation: u64,
2053) -> Router
2054where
2055    R: RecorderRpc + Clone + Send + Sync + 'static,
2056    P: LogPeer + Clone + Send + Sync + 'static,
2057    C: Into<Vec<PeerConfig>>,
2058{
2059    let peers = peers.into();
2060    let recorder_slots = Arc::new(tokio::sync::Semaphore::new(recorder_concurrency));
2061    let log_slots = Arc::new(tokio::sync::Semaphore::new(log_concurrency));
2062    recorder_routes(recorder, peers.clone(), recovery_generation, recorder_slots)
2063        .merge(log_routes(peer, peers, recovery_generation, log_slots))
2064        .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
2065}
2066
2067pub fn node_router<R>(runtime: Arc<NodeRuntime>, recorder: R) -> Router
2068where
2069    R: RecorderRpc + Clone + Send + Sync + 'static,
2070{
2071    node_router_with_limits(
2072        runtime,
2073        recorder,
2074        DEFAULT_CLIENT_CONCURRENCY,
2075        DEFAULT_PEER_CONCURRENCY,
2076    )
2077}
2078
2079pub fn node_router_with_limits<R>(
2080    runtime: Arc<NodeRuntime>,
2081    recorder: R,
2082    client_concurrency: usize,
2083    peer_concurrency: usize,
2084) -> Router
2085where
2086    R: RecorderRpc + Clone + Send + Sync + 'static,
2087{
2088    node_router_with_optional_checkpoint(
2089        runtime,
2090        recorder,
2091        None,
2092        client_concurrency,
2093        peer_concurrency,
2094    )
2095}
2096
2097pub fn node_router_with_checkpoint<R>(
2098    runtime: Arc<NodeRuntime>,
2099    recorder: R,
2100    coordinator: Arc<CheckpointCoordinator>,
2101) -> Router
2102where
2103    R: RecorderRpc + Clone + Send + Sync + 'static,
2104{
2105    node_router_with_checkpoint_and_limits(
2106        runtime,
2107        recorder,
2108        coordinator,
2109        DEFAULT_CLIENT_CONCURRENCY,
2110        DEFAULT_PEER_CONCURRENCY,
2111    )
2112}
2113
2114pub fn node_router_with_checkpoint_and_limits<R>(
2115    runtime: Arc<NodeRuntime>,
2116    recorder: R,
2117    coordinator: Arc<CheckpointCoordinator>,
2118    client_concurrency: usize,
2119    peer_concurrency: usize,
2120) -> Router
2121where
2122    R: RecorderRpc + Clone + Send + Sync + 'static,
2123{
2124    node_router_with_optional_checkpoint(
2125        runtime,
2126        recorder,
2127        Some(coordinator),
2128        client_concurrency,
2129        peer_concurrency,
2130    )
2131}
2132
2133fn node_router_with_optional_checkpoint<R>(
2134    runtime: Arc<NodeRuntime>,
2135    recorder: R,
2136    coordinator: Option<Arc<CheckpointCoordinator>>,
2137    client_concurrency: usize,
2138    peer_concurrency: usize,
2139) -> Router
2140where
2141    R: RecorderRpc + Clone + Send + Sync + 'static,
2142{
2143    let peers = runtime.config.peers.clone();
2144    let recovery_generation = runtime.config.recovery_generation();
2145    let client_slots = Arc::new(tokio::sync::Semaphore::new(client_concurrency));
2146    let recorder_slots = Arc::new(tokio::sync::Semaphore::new(peer_concurrency));
2147    let log_slots = Arc::new(tokio::sync::Semaphore::new(peer_concurrency));
2148    let write_operations = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
2149    let (writer, writer_receiver) = tokio::sync::mpsc::channel(client_concurrency.max(1));
2150    tokio::spawn(writer_loop(
2151        Arc::downgrade(&runtime),
2152        coordinator.clone(),
2153        write_operations.clone(),
2154        writer_receiver,
2155        runtime.config.writer_batch_max,
2156        runtime.config.writer_batch_window,
2157    ));
2158    #[cfg(any(feature = "sql", feature = "graph", feature = "kv"))]
2159    let client_routes: Router = match runtime.config().execution_profile() {
2160        ExecutionProfile::Sqlite => {
2161            #[cfg(feature = "sql")]
2162            {
2163                Router::new()
2164                    .route(WRITE_PATH, post(handle_write))
2165                    .route(READ_PATH, post(handle_read))
2166                    .route(SQL_EXECUTE_PATH, post(handle_sql_execute))
2167                    .route(SQL_QUERY_PATH, post(handle_sql_query))
2168            }
2169            #[cfg(not(feature = "sql"))]
2170            unreachable!("SQL runtime cannot open without the sql feature")
2171        }
2172        ExecutionProfile::Graph => {
2173            #[cfg(feature = "graph")]
2174            {
2175                Router::new()
2176                    .route(GRAPH_PUT_DOCUMENT_PATH, post(handle_graph_put_document))
2177                    .route(
2178                        GRAPH_DELETE_DOCUMENT_PATH,
2179                        post(handle_graph_delete_document),
2180                    )
2181                    .route(GRAPH_GET_DOCUMENT_PATH, post(handle_graph_get_document))
2182                    .route(GRAPH_QUERY_PATH, post(handle_graph_query))
2183            }
2184            #[cfg(not(feature = "graph"))]
2185            unreachable!("graph runtime cannot open without the graph feature")
2186        }
2187        ExecutionProfile::Kv => {
2188            #[cfg(feature = "kv")]
2189            {
2190                Router::new()
2191                    .route(KV_PUT_PATH, post(handle_kv_put))
2192                    .route(KV_DELETE_PATH, post(handle_kv_delete))
2193                    .route(KV_GET_PATH, post(handle_kv_get))
2194                    .route(KV_SCAN_PATH, post(handle_kv_scan))
2195            }
2196            #[cfg(not(feature = "kv"))]
2197            unreachable!("KV runtime cannot open without the kv feature")
2198        }
2199    }
2200    .route_layer(middleware::from_fn_with_state(
2201        ClientGateState {
2202            runtime: runtime.clone(),
2203            slots: client_slots,
2204            coordinator: coordinator.clone(),
2205        },
2206        client_gate,
2207    ))
2208    .with_state(NodeRouteState {
2209        runtime: runtime.clone(),
2210        coordinator: coordinator.clone(),
2211        write_operations: write_operations.clone(),
2212        writer: writer.clone(),
2213    });
2214    #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
2215    let client_routes: Router = Router::new();
2216    let health_routes = Router::new()
2217        .route(LIVEZ_PATH, get(handle_livez))
2218        .route(READYZ_PATH, get(handle_readyz))
2219        .with_state(NodeRouteState {
2220            runtime: runtime.clone(),
2221            coordinator,
2222            write_operations,
2223            writer,
2224        });
2225    recorder_routes(recorder, peers.clone(), recovery_generation, recorder_slots)
2226        .merge(log_routes(
2227            RuntimeLogPeer { runtime },
2228            peers,
2229            recovery_generation,
2230            log_slots,
2231        ))
2232        .merge(client_routes)
2233        .merge(health_routes)
2234        .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
2235}
2236
2237fn recorder_routes<R>(
2238    recorder: R,
2239    peers: Vec<PeerConfig>,
2240    recovery_generation: u64,
2241    slots: Arc<tokio::sync::Semaphore>,
2242) -> Router
2243where
2244    R: RecorderRpc + Clone + Send + Sync + 'static,
2245{
2246    let recorder_peers = peers.clone();
2247    Router::new()
2248        .route(RECORDER_IDENTITY_PATH, post(handle_recorder_identity::<R>))
2249        .route(
2250            RECORDER_STORE_COMMAND_PATH,
2251            post(handle_recorder_store_command::<R>),
2252        )
2253        .route(
2254            RECORDER_FETCH_COMMAND_PATH,
2255            post(handle_recorder_fetch_command::<R>),
2256        )
2257        .route(
2258            RECORDER_INSPECT_PROOF_PATH,
2259            post(handle_recorder_inspect_proof::<R>),
2260        )
2261        .route(
2262            RECORDER_INSPECT_RECORD_PATH,
2263            post(handle_recorder_inspect_record::<R>),
2264        )
2265        .route(
2266            RECORDER_READ_FENCE_PATH,
2267            post(handle_recorder_read_fence::<R>),
2268        )
2269        .route(RECORDER_RECORD_PATH, post(handle_recorder_record::<R>))
2270        .route(
2271            RECORDER_INSTALL_PROOF_PATH,
2272            post(handle_recorder_install_proof::<R>),
2273        )
2274        .route_layer(middleware::from_fn_with_state(
2275            PeerGateState {
2276                peers,
2277                recovery_generation,
2278                protocol_version: RECORDER_PROTOCOL_VERSION,
2279                slots,
2280            },
2281            peer_gate,
2282        ))
2283        .with_state(RecorderRouteState {
2284            recorder,
2285            peers: recorder_peers,
2286        })
2287}
2288
2289fn log_routes<P>(
2290    peer: P,
2291    peers: Vec<PeerConfig>,
2292    recovery_generation: u64,
2293    slots: Arc<tokio::sync::Semaphore>,
2294) -> Router
2295where
2296    P: LogPeer + Clone + Send + Sync + 'static,
2297{
2298    Router::new()
2299        .route(LOG_FETCH_PATH, post(handle_fetch_log::<P>))
2300        .route_layer(middleware::from_fn_with_state(
2301            PeerGateState {
2302                peers,
2303                recovery_generation,
2304                protocol_version: PROTOCOL_VERSION,
2305                slots,
2306            },
2307            peer_gate,
2308        ))
2309        .with_state(LogRouteState { peer })
2310}
2311
2312async fn handle_recorder_identity<R>(
2313    State(state): State<RecorderRouteState<R>>,
2314    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2315    Json(request): Json<RecorderWire<()>>,
2316) -> Response
2317where
2318    R: RecorderRpc + Clone + Send + Sync + 'static,
2319{
2320    if request.version != RECORDER_WIRE_VERSION {
2321        return StatusCode::BAD_REQUEST.into_response();
2322    }
2323    let recorder = state.recorder;
2324    recorder_v2_response(
2325        tokio::task::spawn_blocking(move || {
2326            let _permit = permit;
2327            recorder.recorder_id()
2328        })
2329        .await,
2330    )
2331}
2332
2333async fn handle_recorder_store_command<R>(
2334    State(state): State<RecorderRouteState<R>>,
2335    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2336    Json(request): Json<RecorderWire<StoreCommandV2>>,
2337) -> Response
2338where
2339    R: RecorderRpc + Clone + Send + Sync + 'static,
2340{
2341    if request.version != RECORDER_WIRE_VERSION || !valid_recorder_command(&request.body.command) {
2342        return StatusCode::BAD_REQUEST.into_response();
2343    }
2344    let recorder = state.recorder;
2345    recorder_v2_response(
2346        tokio::task::spawn_blocking(move || {
2347            let _permit = permit;
2348            let body = request.body;
2349            recorder.store_command_for(
2350                body.cluster_id,
2351                body.epoch,
2352                body.config_id,
2353                body.config_digest,
2354                body.command_hash,
2355                body.command,
2356            )
2357        })
2358        .await,
2359    )
2360}
2361
2362async fn handle_recorder_fetch_command<R>(
2363    State(state): State<RecorderRouteState<R>>,
2364    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2365    Json(request): Json<RecorderWire<FetchCommandV2>>,
2366) -> Response
2367where
2368    R: RecorderRpc + Clone + Send + Sync + 'static,
2369{
2370    if request.version != RECORDER_WIRE_VERSION {
2371        return StatusCode::BAD_REQUEST.into_response();
2372    }
2373    let recorder = state.recorder;
2374    recorder_v2_response(
2375        tokio::task::spawn_blocking(move || {
2376            let _permit = permit;
2377            let body = request.body;
2378            recorder.fetch_command_for(
2379                body.cluster_id,
2380                body.epoch,
2381                body.config_id,
2382                body.config_digest,
2383                body.command_hash,
2384            )
2385        })
2386        .await,
2387    )
2388}
2389
2390async fn handle_recorder_inspect_proof<R>(
2391    State(state): State<RecorderRouteState<R>>,
2392    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2393    Json(request): Json<RecorderWire<InspectProofV2>>,
2394) -> Response
2395where
2396    R: RecorderRpc + Clone + Send + Sync + 'static,
2397{
2398    if request.version != RECORDER_WIRE_VERSION {
2399        return StatusCode::BAD_REQUEST.into_response();
2400    }
2401    let recorder = state.recorder;
2402    recorder_v2_response(
2403        tokio::task::spawn_blocking(move || {
2404            let _permit = permit;
2405            recorder.inspect_decision_proof(request.body.slot)
2406        })
2407        .await,
2408    )
2409}
2410
2411async fn handle_recorder_inspect_record<R>(
2412    State(state): State<RecorderRouteState<R>>,
2413    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2414    Json(request): Json<RecorderWire<InspectProofV2>>,
2415) -> Response
2416where
2417    R: RecorderRpc + Clone + Send + Sync + 'static,
2418{
2419    if request.version != RECORDER_WIRE_VERSION {
2420        return StatusCode::BAD_REQUEST.into_response();
2421    }
2422    let recorder = state.recorder;
2423    recorder_v2_response(
2424        tokio::task::spawn_blocking(move || {
2425            let _permit = permit;
2426            recorder.inspect_record_summary(request.body.slot)
2427        })
2428        .await,
2429    )
2430}
2431
2432async fn handle_recorder_read_fence<R>(
2433    State(state): State<RecorderRouteState<R>>,
2434    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2435    Json(request): Json<RecorderWire<ReadFenceRequest>>,
2436) -> Response
2437where
2438    R: RecorderRpc + Clone + Send + Sync + 'static,
2439{
2440    if request.version != RECORDER_WIRE_VERSION {
2441        return StatusCode::BAD_REQUEST.into_response();
2442    }
2443    let recorder = state.recorder;
2444    recorder_v2_response(
2445        tokio::task::spawn_blocking(move || {
2446            let _permit = permit;
2447            recorder.observe_read_fence(request.body)
2448        })
2449        .await,
2450    )
2451}
2452
2453async fn handle_recorder_record<R>(
2454    State(state): State<RecorderRouteState<R>>,
2455    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2456    Extension(authenticated_peer): Extension<AuthenticatedPeer>,
2457    Json(request): Json<RecorderWire<RecordRequest>>,
2458) -> Response
2459where
2460    R: RecorderRpc + Clone + Send + Sync + 'static,
2461{
2462    if request.version != RECORDER_WIRE_VERSION || !valid_recorder_record(&request.body) {
2463        return StatusCode::BAD_REQUEST.into_response();
2464    }
2465    if !authenticated_proposer_admitted(
2466        &authenticated_peer.0,
2467        &request.body.proposal.proposer_id,
2468        &state.peers,
2469    ) {
2470        return recorder_v2_response::<RecordSummary>(Ok(Err(rhiza_quepaxa::Error::Rejected(
2471            RejectReason::InvalidRequest,
2472        ))));
2473    }
2474    let recorder = state.recorder;
2475    recorder_v2_response(
2476        tokio::task::spawn_blocking(move || {
2477            let _permit = permit;
2478            recorder.record(request.body)
2479        })
2480        .await,
2481    )
2482}
2483
2484fn valid_recorder_command(command: &StoredCommand) -> bool {
2485    command.payload.len() <= MAX_COMMAND_BYTES
2486}
2487
2488fn valid_recorder_record(request: &RecordRequest) -> bool {
2489    !request.cluster_id.is_empty()
2490        && request.cluster_id.len() <= MAX_REQUEST_ID_BYTES
2491        && request.command.as_ref().is_none_or(valid_recorder_command)
2492}
2493
2494fn authenticated_proposer_admitted(
2495    authenticated_peer_id: &str,
2496    proposer_id: &str,
2497    peers: &[PeerConfig],
2498) -> bool {
2499    // Record requests carry config identity but not its membership. Configured peers are therefore
2500    // the transport identity authority for records and proofs until rebuilt after a transition.
2501    peers
2502        .iter()
2503        .any(|peer| peer.node_id == authenticated_peer_id)
2504        && peers.iter().any(|peer| peer.node_id == proposer_id)
2505}
2506
2507async fn handle_recorder_install_proof<R>(
2508    State(state): State<RecorderRouteState<R>>,
2509    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2510    Extension(authenticated_peer): Extension<AuthenticatedPeer>,
2511    Json(request): Json<RecorderWire<InstallProofV2>>,
2512) -> Response
2513where
2514    R: RecorderRpc + Clone + Send + Sync + 'static,
2515{
2516    if request.version != RECORDER_WIRE_VERSION {
2517        return StatusCode::BAD_REQUEST.into_response();
2518    }
2519    if !authenticated_proposer_admitted(
2520        &authenticated_peer.0,
2521        &request.body.proof.proposal().proposer_id,
2522        &state.peers,
2523    ) {
2524        return recorder_v2_response::<()>(Ok(Err(rhiza_quepaxa::Error::Rejected(
2525            RejectReason::InvalidRequest,
2526        ))));
2527    }
2528    let recorder = state.recorder;
2529    recorder_v2_response(
2530        tokio::task::spawn_blocking(move || {
2531            let _permit = permit;
2532            let membership = Membership::from_voters(request.body.members)?;
2533            recorder.install_decision_proof(request.body.proof, &membership)
2534        })
2535        .await,
2536    )
2537}
2538
2539fn recorder_v2_response<T: serde::Serialize>(
2540    result: Result<rhiza_quepaxa::Result<T>, tokio::task::JoinError>,
2541) -> Response {
2542    let (status, body) = match result {
2543        Ok(Ok(value)) => (StatusCode::OK, RecorderV2Result::Ok(value)),
2544        Ok(Err(rhiza_quepaxa::Error::Rejected(reason))) => {
2545            (StatusCode::CONFLICT, RecorderV2Result::Rejected(reason))
2546        }
2547        Ok(Err(error)) => (
2548            recorder_error_status(&error),
2549            RecorderV2Result::Error(error.to_string()),
2550        ),
2551        Err(error) => (
2552            StatusCode::INTERNAL_SERVER_ERROR,
2553            RecorderV2Result::Error(error.to_string()),
2554        ),
2555    };
2556    (
2557        status,
2558        Json(RecorderWire {
2559            version: RECORDER_WIRE_VERSION,
2560            body,
2561        }),
2562    )
2563        .into_response()
2564}
2565
2566async fn handle_fetch_log<P>(
2567    State(state): State<LogRouteState<P>>,
2568    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2569    Json(request): Json<FetchLogRequest>,
2570) -> Response
2571where
2572    P: LogPeer + Clone + Send + Sync + 'static,
2573{
2574    if request.from_index == 0 || request.max_entries > MAX_FETCH_ENTRIES {
2575        return StatusCode::BAD_REQUEST.into_response();
2576    }
2577    let peer = state.peer;
2578    let result = tokio::task::spawn_blocking(move || {
2579        let _permit = permit;
2580        peer.fetch_log(request)
2581    })
2582    .await;
2583    match result {
2584        Ok(Ok(response)) => Json(FetchLogHttpResponse::Fetched(response)).into_response(),
2585        Ok(Err(error)) => (
2586            fetch_log_error_status(&error),
2587            Json(FetchLogHttpResponse::Failed(error)),
2588        )
2589            .into_response(),
2590        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
2591    }
2592}
2593
2594#[cfg(feature = "sql")]
2595async fn handle_write(
2596    State(state): State<NodeRouteState>,
2597    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2598    request: Result<Json<WriteRequest>, JsonRejection>,
2599) -> Response {
2600    let request = match client_json(request) {
2601        Ok(request) => request,
2602        Err(response) => return response,
2603    };
2604    let payload = match canonical_put(&request.request_id, &request.key, &request.value) {
2605        Ok(payload) => payload,
2606        Err(error) => return node_error_response(error),
2607    };
2608    let request_id = request.request_id.clone();
2609    let operation = QueuedOperation::KeyValue {
2610        key: request.key,
2611        value: request.value,
2612    };
2613    coordinate_write(state, permit, request_id, payload, operation).await
2614}
2615
2616#[cfg(feature = "sql")]
2617async fn handle_sql_execute(
2618    State(state): State<NodeRouteState>,
2619    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2620    request: Result<Json<SqlExecuteRequest>, JsonRejection>,
2621) -> Response {
2622    let request = match client_json(request) {
2623        Ok(request) => request,
2624        Err(response) => return response,
2625    };
2626    if let Err(error) = validate_field(
2627        "request_id",
2628        &request.request_id,
2629        MAX_REQUEST_ID_BYTES,
2630        false,
2631    ) {
2632        return node_error_response(error);
2633    }
2634    let command = SqlCommand {
2635        request_id: request.request_id.clone(),
2636        statements: request.statements,
2637    };
2638    let payload = match encode_sql_command_with_index(&command) {
2639        Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
2640        Ok(_) => {
2641            return node_error_response(NodeError::InvalidRequest(format!(
2642                "command exceeds {MAX_COMMAND_BYTES} bytes"
2643            )))
2644        }
2645        Err(error) => return node_error_response(error),
2646    };
2647    let request_id = command.request_id.clone();
2648    coordinate_write(
2649        state,
2650        permit,
2651        request_id,
2652        payload,
2653        QueuedOperation::Sql(command),
2654    )
2655    .await
2656}
2657
2658async fn coordinate_write(
2659    state: NodeRouteState,
2660    permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2661    request_id: String,
2662    payload: Vec<u8>,
2663    operation: QueuedOperation,
2664) -> Response {
2665    let deadline = tokio::time::Instant::now() + CLIENT_WRITE_WAIT_TIMEOUT;
2666    let (mut receiver, queued) = {
2667        let mut operations = state.write_operations.lock().await;
2668        if let Some(operation) = operations.get(&request_id) {
2669            if operation.payload != payload {
2670                return client_error_response(
2671                    StatusCode::CONFLICT,
2672                    "request_conflict",
2673                    false,
2674                    "request id is already in flight with a different payload",
2675                    None,
2676                );
2677            }
2678            (operation.result.clone(), None)
2679        } else {
2680            let (sender, receiver) = tokio::sync::watch::channel(None);
2681            operations.insert(
2682                request_id.clone(),
2683                WriteOperation {
2684                    payload: payload.clone(),
2685                    result: receiver.clone(),
2686                },
2687            );
2688            (
2689                receiver,
2690                Some(QueuedWrite {
2691                    request_id: request_id.clone(),
2692                    payload,
2693                    operation,
2694                    permit,
2695                    sender,
2696                }),
2697            )
2698        }
2699    };
2700    if let Some(queued) = queued {
2701        match tokio::time::timeout_at(deadline, state.writer.send(queued)).await {
2702            Ok(Ok(())) => {}
2703            Ok(Err(_)) => {
2704                state.write_operations.lock().await.remove(&request_id);
2705                return client_error_response(
2706                    StatusCode::SERVICE_UNAVAILABLE,
2707                    "durability_unavailable",
2708                    true,
2709                    "writer queue is unavailable",
2710                    None,
2711                );
2712            }
2713            Err(_) => {
2714                state.write_operations.lock().await.remove(&request_id);
2715                return client_error_response(
2716                    StatusCode::SERVICE_UNAVAILABLE,
2717                    "write_timeout",
2718                    true,
2719                    "write did not enter the queue before the response deadline",
2720                    None,
2721                );
2722            }
2723        }
2724    }
2725    let wait = async {
2726        loop {
2727            if let Some(result) = receiver.borrow().clone() {
2728                return result;
2729            }
2730            if receiver.changed().await.is_err() {
2731                return WriteOperationResult::DurabilityUnavailable;
2732            }
2733        }
2734    };
2735    match tokio::time::timeout_at(deadline, wait).await {
2736        Ok(WriteOperationResult::Runtime(Ok(response))) => match response {
2737            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
2738            ClientWriteResponse::Unavailable => {
2739                unreachable!("no execution profiles are compiled in")
2740            }
2741            #[cfg(feature = "sql")]
2742            ClientWriteResponse::KeyValue(response) => Json(response).into_response(),
2743            #[cfg(feature = "sql")]
2744            ClientWriteResponse::Sql(response) => Json(response).into_response(),
2745            #[cfg(feature = "graph")]
2746            ClientWriteResponse::Graph(outcome) => {
2747                Json(graph_mutation_response(outcome)).into_response()
2748            }
2749            #[cfg(feature = "kv")]
2750            ClientWriteResponse::Kv(outcome) => Json(kv_mutation_response(outcome)).into_response(),
2751        },
2752        Ok(WriteOperationResult::Runtime(Err(error))) => node_error_response(error),
2753        Ok(WriteOperationResult::DurabilityUnavailable) => client_error_response(
2754            StatusCode::SERVICE_UNAVAILABLE,
2755            "durability_unavailable",
2756            true,
2757            "durability confirmation is unavailable",
2758            None,
2759        ),
2760        Err(_) => client_error_response(
2761            StatusCode::SERVICE_UNAVAILABLE,
2762            "write_timeout",
2763            true,
2764            "write did not complete before the response deadline",
2765            None,
2766        ),
2767    }
2768}
2769
2770async fn writer_loop(
2771    runtime: std::sync::Weak<NodeRuntime>,
2772    coordinator: Option<Arc<CheckpointCoordinator>>,
2773    write_operations: Arc<tokio::sync::Mutex<HashMap<String, WriteOperation>>>,
2774    mut receiver: tokio::sync::mpsc::Receiver<QueuedWrite>,
2775    batch_max: usize,
2776    batch_window: Duration,
2777) {
2778    while let Some(first) = receiver.recv().await {
2779        let mut queued = Vec::with_capacity(batch_max);
2780        queued.push(first);
2781        let deadline = tokio::time::Instant::now() + batch_window;
2782        while queued.len() < batch_max {
2783            match tokio::time::timeout_at(deadline, receiver.recv()).await {
2784                Ok(Some(next)) => queued.push(next),
2785                Ok(None) | Err(_) => break,
2786            }
2787        }
2788
2789        let mut dispatch = Vec::with_capacity(queued.len());
2790        let mut members = Vec::with_capacity(queued.len());
2791        for queued in queued {
2792            members.push(RuntimeBatchMember {
2793                #[cfg(feature = "sql")]
2794                request_id: queued.request_id.clone(),
2795                payload: queued.payload,
2796                operation: queued.operation,
2797            });
2798            dispatch.push((queued.request_id, queued.sender, queued.permit));
2799        }
2800
2801        let Some(runtime) = runtime.upgrade() else {
2802            for (request_id, sender, _permit) in dispatch {
2803                sender.send_replace(Some(WriteOperationResult::DurabilityUnavailable));
2804                write_operations.lock().await.remove(&request_id);
2805            }
2806            break;
2807        };
2808
2809        let blocking_runtime = runtime.clone();
2810        let results =
2811            tokio::task::spawn_blocking(move || blocking_runtime.execute_client_batch(members))
2812                .await
2813                .unwrap_or_else(|error| {
2814                    (0..dispatch.len())
2815                        .map(|_| {
2816                            Err(NodeError::Fatal(format!(
2817                                "writer batch task failed: {error}"
2818                            )))
2819                        })
2820                        .collect()
2821                });
2822        let dispatch = dispatch
2823            .into_iter()
2824            .map(|(request_id, sender, permit)| {
2825                drop(permit);
2826                (request_id, sender)
2827            })
2828            .collect::<Vec<_>>();
2829
2830        let committed_index = results
2831            .iter()
2832            .filter_map(|result| result.as_ref().ok().map(ClientWriteResponse::applied_index))
2833            .max();
2834        let durability_available = if let Some(index) = committed_index {
2835            confirm_write_durability(runtime.as_ref(), coordinator.as_deref(), index)
2836                .await
2837                .is_ok()
2838        } else {
2839            true
2840        };
2841
2842        for ((request_id, sender), result) in dispatch.into_iter().zip(results) {
2843            let delivered = if !durability_available && result.is_ok() {
2844                WriteOperationResult::DurabilityUnavailable
2845            } else {
2846                WriteOperationResult::Runtime(result)
2847            };
2848            sender.send_replace(Some(delivered));
2849            write_operations.lock().await.remove(&request_id);
2850        }
2851    }
2852}
2853
2854#[cfg(feature = "sql")]
2855async fn handle_sql_query(
2856    State(state): State<NodeRouteState>,
2857    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2858    request: Result<Json<SqlQueryRequest>, JsonRejection>,
2859) -> Response {
2860    let request = match client_json(request) {
2861        Ok(request) => request,
2862        Err(response) => return response,
2863    };
2864    let runtime = state.runtime;
2865    let consistency = request
2866        .consistency
2867        .unwrap_or(runtime.config.read_consistency());
2868    let max_rows = request.max_rows.unwrap_or(DEFAULT_SQL_MAX_ROWS);
2869    if max_rows == 0 || max_rows > MAX_SQL_MAX_ROWS {
2870        return node_error_response(NodeError::InvalidRequest(format!(
2871            "max_rows must be between 1 and {MAX_SQL_MAX_ROWS}"
2872        )));
2873    }
2874    let result = tokio::task::spawn_blocking(move || {
2875        let _permit = permit;
2876        runtime.query_sql(&request.statement, consistency, max_rows)
2877    })
2878    .await;
2879    match result {
2880        Ok(Ok(response)) => sql_query_http_response(response),
2881        Ok(Err(error)) => node_error_response(error),
2882        Err(error) => client_task_error(error),
2883    }
2884}
2885
2886#[cfg(feature = "sql")]
2887fn sql_query_http_response(response: SqlQueryResponse) -> Response {
2888    match serde_json::to_vec(&response) {
2889        Ok(encoded) if encoded.len() <= MAX_SQL_RESPONSE_BYTES => (
2890            [(axum::http::header::CONTENT_TYPE, "application/json")],
2891            encoded,
2892        )
2893            .into_response(),
2894        Ok(_) => node_error_response(NodeError::InvalidRequest(format!(
2895            "SQL response exceeds {MAX_SQL_RESPONSE_BYTES} bytes"
2896        ))),
2897        Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
2898    }
2899}
2900
2901#[cfg(feature = "graph")]
2902async fn handle_graph_put_document(
2903    State(state): State<NodeRouteState>,
2904    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2905    request: Result<Json<GraphPutDocumentRequest>, JsonRejection>,
2906) -> Response {
2907    let request = match client_json(request) {
2908        Ok(request) => request,
2909        Err(response) => return response,
2910    };
2911    let value = match GraphValueV1::try_from(request.value) {
2912        Ok(value) => value,
2913        Err(error) => return node_error_response(error),
2914    };
2915    let command = match GraphCommandV1::put_document(request.request_id, request.id, value) {
2916        Ok(command) => command,
2917        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2918    };
2919    execute_graph_mutation(state, permit, command).await
2920}
2921
2922#[cfg(feature = "graph")]
2923async fn handle_graph_delete_document(
2924    State(state): State<NodeRouteState>,
2925    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2926    request: Result<Json<GraphDeleteDocumentRequest>, JsonRejection>,
2927) -> Response {
2928    let request = match client_json(request) {
2929        Ok(request) => request,
2930        Err(response) => return response,
2931    };
2932    let command = match GraphCommandV1::delete_document(request.request_id, request.id) {
2933        Ok(command) => command,
2934        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2935    };
2936    execute_graph_mutation(state, permit, command).await
2937}
2938
2939#[cfg(feature = "graph")]
2940async fn execute_graph_mutation(
2941    state: NodeRouteState,
2942    permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2943    command: GraphCommandV1,
2944) -> Response {
2945    let payload = match encode_replicated_graph_command(&command) {
2946        Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
2947        Ok(_) => {
2948            return node_error_response(NodeError::InvalidRequest(format!(
2949                "command exceeds {MAX_COMMAND_BYTES} bytes"
2950            )))
2951        }
2952        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2953    };
2954    coordinate_write(
2955        state,
2956        permit,
2957        command.request_id().to_owned(),
2958        payload,
2959        QueuedOperation::Graph(command),
2960    )
2961    .await
2962}
2963
2964#[cfg(feature = "graph")]
2965async fn handle_graph_get_document(
2966    State(state): State<NodeRouteState>,
2967    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2968    request: Result<Json<GraphGetDocumentRequest>, JsonRejection>,
2969) -> Response {
2970    let request = match client_json(request) {
2971        Ok(request) => request,
2972        Err(response) => return response,
2973    };
2974    let runtime = state.runtime;
2975    let consistency = request
2976        .consistency
2977        .unwrap_or(runtime.config.read_consistency());
2978    let result = tokio::task::spawn_blocking(move || {
2979        let _permit = permit;
2980        runtime.get_graph_document(&request.id, consistency)
2981    })
2982    .await;
2983    match result {
2984        Ok(Ok(response)) => Json(GraphGetDocumentResponse {
2985            value: response.value.map(GraphValueDto::from),
2986            applied_index: response.applied_index,
2987            hash: response.hash,
2988        })
2989        .into_response(),
2990        Ok(Err(error)) => node_error_response(error),
2991        Err(error) => client_task_error(error),
2992    }
2993}
2994
2995#[cfg(feature = "graph")]
2996fn with_graph_client_permit<T>(
2997    permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2998    response_work: impl FnOnce() -> T,
2999) -> T {
3000    let result = response_work();
3001    drop(permit);
3002    result
3003}
3004
3005#[cfg(feature = "graph")]
3006async fn handle_graph_query(
3007    State(state): State<NodeRouteState>,
3008    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3009    request: Result<Json<GraphQueryRequest>, JsonRejection>,
3010) -> Response {
3011    let request = match client_json(request) {
3012        Ok(request) => request,
3013        Err(response) => return response,
3014    };
3015    let parameters = match request
3016        .statement
3017        .parameters
3018        .into_iter()
3019        .map(|(name, value)| GraphParameterValue::try_from(value).map(|value| (name, value)))
3020        .collect::<Result<BTreeMap<_, _>, _>>()
3021    {
3022        Ok(parameters) => parameters,
3023        Err(error) => return node_error_response(error),
3024    };
3025    let runtime = state.runtime;
3026    let consistency = request
3027        .consistency
3028        .unwrap_or(runtime.config.read_consistency());
3029    let max_rows = request.max_rows.unwrap_or(DEFAULT_GRAPH_MAX_ROWS);
3030    if max_rows == 0 || max_rows > MAX_GRAPH_MAX_ROWS {
3031        return node_error_response(NodeError::InvalidRequest(format!(
3032            "max_rows must be between 1 and {MAX_GRAPH_MAX_ROWS}"
3033        )));
3034    }
3035    let result = tokio::task::spawn_blocking(move || {
3036        runtime.query_graph(
3037            &request.statement.cypher,
3038            &parameters,
3039            consistency,
3040            max_rows,
3041        )
3042    })
3043    .await;
3044    with_graph_client_permit(permit, || match result {
3045        Ok(Ok(result)) => {
3046            let response = GraphQueryResponse::from(result);
3047            match serde_json::to_vec(&response) {
3048                Ok(encoded) if encoded.len() <= MAX_GRAPH_RESPONSE_BYTES => (
3049                    [(axum::http::header::CONTENT_TYPE, "application/json")],
3050                    encoded,
3051                )
3052                    .into_response(),
3053                Ok(_) => node_error_response(NodeError::InvalidRequest(format!(
3054                    "graph response exceeds {MAX_GRAPH_RESPONSE_BYTES} bytes"
3055                ))),
3056                Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
3057            }
3058        }
3059        Ok(Err(error)) => node_error_response(error),
3060        Err(error) => client_task_error(error),
3061    })
3062}
3063
3064#[cfg(feature = "kv")]
3065async fn handle_kv_put(
3066    State(state): State<NodeRouteState>,
3067    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3068    request: Result<Json<KvPutRequest>, JsonRejection>,
3069) -> Response {
3070    let request = match client_json(request) {
3071        Ok(request) => request,
3072        Err(response) => return response,
3073    };
3074    let key = match decode_base64("key", &request.key) {
3075        Ok(value) => value,
3076        Err(error) => return node_error_response(error),
3077    };
3078    let value = match decode_base64("value", &request.value) {
3079        Ok(value) => value,
3080        Err(error) => return node_error_response(error),
3081    };
3082    let command = match KvCommandV1::put(request.request_id, key, value) {
3083        Ok(command) => command,
3084        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3085    };
3086    execute_kv_mutation(state, permit, command).await
3087}
3088
3089#[cfg(feature = "kv")]
3090async fn handle_kv_delete(
3091    State(state): State<NodeRouteState>,
3092    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3093    request: Result<Json<KvDeleteRequest>, JsonRejection>,
3094) -> Response {
3095    let request = match client_json(request) {
3096        Ok(request) => request,
3097        Err(response) => return response,
3098    };
3099    let key = match decode_base64("key", &request.key) {
3100        Ok(value) => value,
3101        Err(error) => return node_error_response(error),
3102    };
3103    let command = match KvCommandV1::delete(request.request_id, key) {
3104        Ok(command) => command,
3105        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3106    };
3107    execute_kv_mutation(state, permit, command).await
3108}
3109
3110#[cfg(feature = "kv")]
3111async fn execute_kv_mutation(
3112    state: NodeRouteState,
3113    permit: Arc<tokio::sync::OwnedSemaphorePermit>,
3114    command: KvCommandV1,
3115) -> Response {
3116    let payload = match encode_replicated_kv_command(&command) {
3117        Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
3118        Ok(_) => {
3119            return node_error_response(NodeError::InvalidRequest(format!(
3120                "command exceeds {MAX_COMMAND_BYTES} bytes"
3121            )))
3122        }
3123        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3124    };
3125    coordinate_write(
3126        state,
3127        permit,
3128        command.request_id().to_owned(),
3129        payload,
3130        QueuedOperation::Kv(command),
3131    )
3132    .await
3133}
3134
3135#[cfg(feature = "kv")]
3136async fn handle_kv_get(
3137    State(state): State<NodeRouteState>,
3138    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3139    request: Result<Json<KvGetRequest>, JsonRejection>,
3140) -> Response {
3141    let request = match client_json(request) {
3142        Ok(request) => request,
3143        Err(response) => return response,
3144    };
3145    let key = match decode_base64("key", &request.key) {
3146        Ok(value) => value,
3147        Err(error) => return node_error_response(error),
3148    };
3149    let runtime = state.runtime;
3150    let consistency = request
3151        .consistency
3152        .unwrap_or(runtime.config.read_consistency());
3153    let result = tokio::task::spawn_blocking(move || {
3154        let _permit = permit;
3155        runtime.get_kv(&key, consistency)
3156    })
3157    .await;
3158    match result {
3159        Ok(Ok(response)) => Json(KvGetResponse {
3160            value: response.value.as_deref().map(encode_base64),
3161            applied_index: response.applied_index,
3162            hash: response.hash,
3163        })
3164        .into_response(),
3165        Ok(Err(error)) => node_error_response(error),
3166        Err(error) => client_task_error(error),
3167    }
3168}
3169
3170#[cfg(feature = "kv")]
3171enum DecodedKvScan {
3172    Range {
3173        start: Vec<u8>,
3174        end: Option<Vec<u8>>,
3175    },
3176    Prefix(Vec<u8>),
3177}
3178
3179#[cfg(feature = "kv")]
3180async fn handle_kv_scan(
3181    State(state): State<NodeRouteState>,
3182    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3183    request: Result<Json<KvScanRequest>, JsonRejection>,
3184) -> Response {
3185    let request = match client_json(request) {
3186        Ok(request) => request,
3187        Err(response) => return response,
3188    };
3189    let limit = request
3190        .limit
3191        .unwrap_or(usize::try_from(DEFAULT_KV_SCAN_LIMIT).expect("u32 fits usize"));
3192    if limit == 0 || limit > MAX_KV_SCAN_ROWS {
3193        return node_error_response(NodeError::InvalidRequest(format!(
3194            "limit must be between 1 and {MAX_KV_SCAN_ROWS}"
3195        )));
3196    }
3197    let cursor = match request.cursor {
3198        Some(cursor) => match decode_base64("cursor", &cursor) {
3199            Ok(cursor) => Some(cursor),
3200            Err(error) => return node_error_response(error),
3201        },
3202        None => None,
3203    };
3204    let scan = match (request.prefix, request.start, request.end) {
3205        (Some(prefix), None, None) => match decode_base64("prefix", &prefix) {
3206            Ok(prefix) => DecodedKvScan::Prefix(prefix),
3207            Err(error) => return node_error_response(error),
3208        },
3209        (None, Some(start), end) => {
3210            let start = match decode_base64("start", &start) {
3211                Ok(start) => start,
3212                Err(error) => return node_error_response(error),
3213            };
3214            let end = match end {
3215                Some(end) => match decode_base64("end", &end) {
3216                    Ok(end) => Some(end),
3217                    Err(error) => return node_error_response(error),
3218                },
3219                None => None,
3220            };
3221            DecodedKvScan::Range { start, end }
3222        }
3223        _ => {
3224            return node_error_response(NodeError::InvalidRequest(
3225                "provide either prefix alone or start with optional end".into(),
3226            ))
3227        }
3228    };
3229    let runtime = state.runtime;
3230    let consistency = request
3231        .consistency
3232        .unwrap_or(runtime.config.read_consistency());
3233    let result = tokio::task::spawn_blocking(move || {
3234        let _permit = permit;
3235        match scan {
3236            DecodedKvScan::Range { start, end } => runtime.scan_kv_range(
3237                &start,
3238                end.as_deref(),
3239                limit,
3240                cursor.as_deref(),
3241                consistency,
3242            ),
3243            DecodedKvScan::Prefix(prefix) => {
3244                runtime.scan_kv_prefix(&prefix, limit, cursor.as_deref(), consistency)
3245            }
3246        }
3247    })
3248    .await;
3249    match result {
3250        Ok(Ok(result)) => {
3251            let response = KvScanResponse {
3252                entries: result
3253                    .rows()
3254                    .iter()
3255                    .map(|row| KvScanEntryDto {
3256                        key: encode_base64(row.key()),
3257                        value: encode_base64(row.value()),
3258                    })
3259                    .collect(),
3260                next_cursor: result.next_cursor().map(encode_base64),
3261                applied_index: result.tip().applied_index(),
3262                hash: result.tip().applied_hash(),
3263            };
3264            match serde_json::to_vec(&response) {
3265                Ok(encoded) if encoded.len() <= MAX_KV_SCAN_RESPONSE_BYTES => {
3266                    Json(response).into_response()
3267                }
3268                Ok(_) => node_error_response(NodeError::ResourceExhausted(format!(
3269                    "KV scan response exceeds {MAX_KV_SCAN_RESPONSE_BYTES} bytes"
3270                ))),
3271                Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
3272            }
3273        }
3274        Ok(Err(error)) => node_error_response(error),
3275        Err(error) => client_task_error(error),
3276    }
3277}
3278
3279#[cfg(feature = "sql")]
3280async fn handle_read(
3281    State(state): State<NodeRouteState>,
3282    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3283    request: Result<Json<ReadRequest>, JsonRejection>,
3284) -> Response {
3285    let request = match client_json(request) {
3286        Ok(request) => request,
3287        Err(response) => return response,
3288    };
3289    let runtime = state.runtime;
3290    let consistency = request
3291        .consistency
3292        .unwrap_or(runtime.config.read_consistency());
3293    let result = tokio::task::spawn_blocking(move || {
3294        let _permit = permit;
3295        runtime.read(&request.key, consistency)
3296    })
3297    .await;
3298    match result {
3299        Ok(Ok(response)) => Json(response).into_response(),
3300        Ok(Err(error)) => node_error_response(error),
3301        Err(error) => client_task_error(error),
3302    }
3303}
3304
3305async fn peer_gate(
3306    State(state): State<PeerGateState>,
3307    mut request: Request,
3308    next: Next,
3309) -> Response {
3310    if !recovery_generation_matches(request.headers(), state.recovery_generation) {
3311        return StatusCode::UNAUTHORIZED.into_response();
3312    }
3313    let Some(authenticated_peer) =
3314        authenticated_peer(request.headers(), &state.peers, state.protocol_version)
3315    else {
3316        return StatusCode::UNAUTHORIZED.into_response();
3317    };
3318    let permit = match state.slots.try_acquire_owned() {
3319        Ok(permit) => Arc::new(permit),
3320        Err(_) => return StatusCode::TOO_MANY_REQUESTS.into_response(),
3321    };
3322    request.extensions_mut().insert(permit);
3323    request
3324        .extensions_mut()
3325        .insert(AuthenticatedPeer(authenticated_peer));
3326    next.run(request).await
3327}
3328
3329async fn client_gate(
3330    State(state): State<ClientGateState>,
3331    mut request: Request,
3332    next: Next,
3333) -> Response {
3334    if !client_authenticated(request.headers(), state.runtime.config.client_token()) {
3335        return client_error_response(
3336            StatusCode::UNAUTHORIZED,
3337            "unauthorized",
3338            false,
3339            "client authentication failed",
3340            None,
3341        );
3342    }
3343    if let Some(response) = runtime_readiness_response(state.runtime.as_ref()) {
3344        return response;
3345    }
3346    if client_write_path(request.uri().path())
3347        && state
3348            .coordinator
3349            .as_ref()
3350            .is_some_and(|coordinator| coordinator.write_allowed().is_err())
3351    {
3352        return client_error_response(
3353            StatusCode::SERVICE_UNAVAILABLE,
3354            "writes_unavailable",
3355            true,
3356            "writes are temporarily unavailable",
3357            None,
3358        );
3359    }
3360    let permit = match state.slots.try_acquire_owned() {
3361        Ok(permit) => Arc::new(permit),
3362        Err(_) => {
3363            return client_error_response(
3364                StatusCode::TOO_MANY_REQUESTS,
3365                "overloaded",
3366                true,
3367                "client request capacity is exhausted",
3368                None,
3369            )
3370        }
3371    };
3372    request.extensions_mut().insert(permit);
3373    next.run(request).await
3374}
3375
3376fn client_write_path(path: &str) -> bool {
3377    #[cfg(feature = "sql")]
3378    if matches!(path, WRITE_PATH | SQL_EXECUTE_PATH) {
3379        return true;
3380    }
3381    #[cfg(feature = "graph")]
3382    if matches!(path, GRAPH_PUT_DOCUMENT_PATH | GRAPH_DELETE_DOCUMENT_PATH) {
3383        return true;
3384    }
3385    #[cfg(feature = "kv")]
3386    if matches!(path, KV_PUT_PATH | KV_DELETE_PATH) {
3387        return true;
3388    }
3389    false
3390}
3391
3392async fn handle_livez() -> StatusCode {
3393    StatusCode::OK
3394}
3395
3396async fn handle_readyz(State(state): State<NodeRouteState>) -> StatusCode {
3397    if state.runtime.is_ready()
3398        && state
3399            .runtime
3400            .configuration_state()
3401            .is_ok_and(|configuration| configuration.is_active())
3402        && state
3403            .coordinator
3404            .as_ref()
3405            .is_none_or(|coordinator| coordinator.health() == DurabilityHealth::Available)
3406    {
3407        StatusCode::OK
3408    } else {
3409        StatusCode::SERVICE_UNAVAILABLE
3410    }
3411}
3412
3413fn next_sync_flush_retry(current: Duration) -> Duration {
3414    current.saturating_mul(2).min(SYNC_FLUSH_RETRY_MAX)
3415}
3416
3417/// Confirms that a committed write has reached the configured durability boundary.
3418///
3419/// Synchronous archive I/O failures are retried with bounded backoff until the
3420/// archive recovers or the runtime begins shutdown.
3421pub async fn confirm_write_durability(
3422    runtime: &NodeRuntime,
3423    coordinator: Option<&CheckpointCoordinator>,
3424    index: LogIndex,
3425) -> Result<(), DurabilityError> {
3426    let Some(coordinator) = coordinator else {
3427        return Ok(());
3428    };
3429    coordinator.note_committed(index);
3430    if !matches!(coordinator.mode(), DurabilityMode::Sync) {
3431        return Ok(());
3432    }
3433
3434    let mut retry_delay = SYNC_FLUSH_RETRY_INITIAL;
3435    loop {
3436        if runtime.operation_cancelled.load(Ordering::Acquire) {
3437            return Err(DurabilityError::Unavailable);
3438        }
3439        match coordinator.flush_runtime(runtime, index).await {
3440            Ok(tip) if tip.index() >= index => return Ok(()),
3441            Ok(tip) => {
3442                return Err(DurabilityError::LocalLogGap {
3443                    expected: index,
3444                    actual: Some(tip.index()),
3445                })
3446            }
3447            Err(DurabilityError::Archive(_) | DurabilityError::Io(_)) => {
3448                let cancelled = runtime.operation_cancelled_notify.notified();
3449                tokio::pin!(cancelled);
3450                cancelled.as_mut().enable();
3451                if runtime.operation_cancelled.load(Ordering::Acquire) {
3452                    return Err(DurabilityError::Unavailable);
3453                }
3454                tokio::select! {
3455                    () = tokio::time::sleep(retry_delay) => {}
3456                    () = &mut cancelled => return Err(DurabilityError::Unavailable),
3457                }
3458                retry_delay = next_sync_flush_retry(retry_delay);
3459            }
3460            Err(error) => return Err(error),
3461        }
3462    }
3463}
3464
3465fn authenticated_peer(
3466    headers: &HeaderMap,
3467    peers: &[PeerConfig],
3468    protocol_version: &str,
3469) -> Option<String> {
3470    if header_text(headers, VERSION_HEADER) != Some(protocol_version) {
3471        return None;
3472    }
3473    let node_id = header_text(headers, NODE_ID_HEADER)?;
3474    let token = bearer_token(headers)?;
3475    peer_credentials_authenticated(node_id, token, peers).then(|| node_id.to_owned())
3476}
3477
3478fn peer_credentials_authenticated(node_id: &str, token: &str, peers: &[PeerConfig]) -> bool {
3479    peers
3480        .iter()
3481        .find(|peer| peer.node_id == node_id)
3482        .is_some_and(|peer| secrets_equal(peer.token.as_bytes(), token.as_bytes()))
3483}
3484
3485fn recovery_generation_matches(headers: &HeaderMap, expected: u64) -> bool {
3486    let expected = expected.to_string();
3487    header_text(headers, RECOVERY_GENERATION_HEADER) == Some(expected.as_str())
3488}
3489
3490fn client_authenticated(headers: &HeaderMap, expected_token: &str) -> bool {
3491    !expected_token.is_empty()
3492        && version_matches(headers)
3493        && bearer_token(headers)
3494            .is_some_and(|token| secrets_equal(expected_token.as_bytes(), token.as_bytes()))
3495}
3496
3497fn version_matches(headers: &HeaderMap) -> bool {
3498    header_text(headers, VERSION_HEADER) == Some(PROTOCOL_VERSION)
3499}
3500
3501fn header_text<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
3502    headers.get(name)?.to_str().ok()
3503}
3504
3505fn bearer_token(headers: &HeaderMap) -> Option<&str> {
3506    header_text(headers, "authorization")?.strip_prefix("Bearer ")
3507}
3508
3509fn secrets_equal(left: &[u8], right: &[u8]) -> bool {
3510    if left.len() != right.len() {
3511        return false;
3512    }
3513    left.iter()
3514        .zip(right)
3515        .fold(0_u8, |difference, (left, right)| {
3516            difference | (left ^ right)
3517        })
3518        == 0
3519}
3520
3521fn node_error_response(error: NodeError) -> Response {
3522    let (status, statement_index) = match &error {
3523        NodeError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, None),
3524        #[cfg(feature = "sql")]
3525        NodeError::InvalidSqlStatement {
3526            statement_index, ..
3527        } => (StatusCode::BAD_REQUEST, Some(*statement_index)),
3528        #[cfg(feature = "sql")]
3529        NodeError::RequestConflict(_) => (StatusCode::CONFLICT, None),
3530        NodeError::PreconditionFailed(_) => (StatusCode::CONFLICT, None),
3531        NodeError::SnapshotRequired(_)
3532        | NodeError::Unavailable(_)
3533        | NodeError::ResourceExhausted(_)
3534        | NodeError::ConfigurationTransition { .. }
3535        | NodeError::Contention(_)
3536        | NodeError::WinnerLimitExceeded => (StatusCode::SERVICE_UNAVAILABLE, None),
3537        NodeError::DataRootLocked(_)
3538        | NodeError::UnsupportedAckMode(_)
3539        | NodeError::ExecutionProfileMismatch { .. }
3540        | NodeError::Storage(_)
3541        | NodeError::Reconciliation(_)
3542        | NodeError::Invariant(_)
3543        | NodeError::Fatal(_) => (StatusCode::INTERNAL_SERVER_ERROR, None),
3544    };
3545    let classification = error.classification();
3546    if !matches!(&error, NodeError::Fatal(_)) {
3547        eprintln!(
3548            "node request failed (code={}, retryable={}): {}",
3549            classification.code(),
3550            classification.retryable(),
3551            escaped_error_detail(&error)
3552        );
3553    }
3554    client_error_response(
3555        status,
3556        classification.code(),
3557        classification.retryable(),
3558        node_error_message(status),
3559        statement_index,
3560    )
3561}
3562
3563fn node_error_message(status: StatusCode) -> &'static str {
3564    match status {
3565        StatusCode::BAD_REQUEST => "request could not be processed",
3566        StatusCode::CONFLICT => "request conflicts with current state",
3567        StatusCode::SERVICE_UNAVAILABLE => "service is temporarily unavailable",
3568        _ => "internal server error",
3569    }
3570}
3571
3572const MAX_ESCAPED_ERROR_DETAIL_BYTES: usize = 4 * 1024;
3573const ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER: &str = "...[truncated]";
3574
3575fn escaped_error_detail(error: &dyn fmt::Display) -> String {
3576    let detail = error.to_string();
3577    let mut escaped = String::with_capacity(detail.len().min(MAX_ESCAPED_ERROR_DETAIL_BYTES));
3578    for character in detail.chars() {
3579        let character_start = escaped.len();
3580        for escaped_character in character.escape_default() {
3581            if escaped.len()
3582                + escaped_character.len_utf8()
3583                + ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER.len()
3584                > MAX_ESCAPED_ERROR_DETAIL_BYTES
3585            {
3586                escaped.truncate(character_start);
3587                escaped.push_str(ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER);
3588                return escaped;
3589            }
3590            escaped.push(escaped_character);
3591        }
3592    }
3593    escaped
3594}
3595
3596#[allow(clippy::result_large_err)]
3597fn client_json<T>(request: Result<Json<T>, JsonRejection>) -> Result<T, Response> {
3598    request.map(|Json(request)| request).map_err(|rejection| {
3599        let status = rejection.status();
3600        eprintln!("invalid JSON request: {}", escaped_error_detail(&rejection));
3601        client_json_error_response(status)
3602    })
3603}
3604
3605fn client_json_error_response(status: StatusCode) -> Response {
3606    let code = if status == StatusCode::PAYLOAD_TOO_LARGE {
3607        "payload_too_large"
3608    } else {
3609        "invalid_json"
3610    };
3611    client_error_response(status, code, false, "request body is invalid", None)
3612}
3613
3614fn client_task_error(error: tokio::task::JoinError) -> Response {
3615    eprintln!("request task failed: {}", escaped_error_detail(&error));
3616    client_error_response(
3617        StatusCode::INTERNAL_SERVER_ERROR,
3618        "task_failed",
3619        false,
3620        "request task failed",
3621        None,
3622    )
3623}
3624
3625fn client_error_response(
3626    status: StatusCode,
3627    code: impl Into<String>,
3628    retryable: bool,
3629    message: impl Into<String>,
3630    statement_index: Option<usize>,
3631) -> Response {
3632    (
3633        status,
3634        Json(ClientErrorResponse {
3635            code: code.into(),
3636            retryable,
3637            message: message.into(),
3638            statement_index,
3639        }),
3640    )
3641        .into_response()
3642}
3643
3644pub fn install_successor_recorder(
3645    recorder: &RecorderFileStore,
3646    next_config_id: u64,
3647    membership: Membership,
3648    stop: &StopInformation,
3649) -> Result<rhiza_quepaxa::ConfigurationState, NodeError> {
3650    if stop.entry.config_id.checked_add(1) != Some(next_config_id) {
3651        return Err(NodeError::PreconditionFailed(
3652            "successor identity does not match the Stop proof".into(),
3653        ));
3654    }
3655    recorder
3656        .install_successor_from_proof(membership, &stop.proof)
3657        .map_err(|error| NodeError::Reconciliation(error.to_string()))
3658}
3659
3660pub fn recover_successor_recorder_after_checkpoint(
3661    recorder: &RecorderFileStore,
3662    config: &NodeConfig,
3663    next_config_id: u64,
3664    membership: Membership,
3665    stop: &StopInformation,
3666) -> Result<rhiza_quepaxa::ConfigurationState, NodeError> {
3667    let installed = install_successor_recorder(recorder, next_config_id, membership.clone(), stop)?;
3668    let log = FileLogStore::open_with_configuration(
3669        config.data_dir.join("consensus/log"),
3670        &config.cluster_id,
3671        config.epoch,
3672        config.log_initial_configuration.clone(),
3673    )
3674    .map_err(|error| NodeError::Storage(error.to_string()))?;
3675    let recovered_configuration = log
3676        .configuration_state()
3677        .map_err(|error| NodeError::Storage(error.to_string()))?;
3678    if !recovered_configuration.is_active() {
3679        return Ok(installed);
3680    }
3681    if recovered_configuration.config_id() != next_config_id
3682        || recovered_configuration.digest() != membership.digest()
3683    {
3684        return Err(NodeError::Reconciliation(
3685            "recovered successor qlog configuration does not match the target bundle".into(),
3686        ));
3687    }
3688    let tip = log
3689        .logical_state()
3690        .map_err(|error| NodeError::Storage(error.to_string()))?
3691        .tip
3692        .ok_or_else(|| NodeError::Reconciliation("recovered successor qlog is empty".into()))?;
3693    recorder
3694        .recover_successor_activation_from_checkpoint(
3695            stop.entry.index,
3696            stop.entry.hash,
3697            tip.index(),
3698            tip.hash(),
3699        )
3700        .map_err(|error| NodeError::Reconciliation(error.to_string()))
3701}
3702
3703fn recorder_error_status(error: &rhiza_quepaxa::Error) -> StatusCode {
3704    match error {
3705        rhiza_quepaxa::Error::NoQuorum
3706        | rhiza_quepaxa::Error::CommandUnavailable
3707        | rhiza_quepaxa::Error::Io(_)
3708        | rhiza_quepaxa::Error::RecorderRootLocked(_) => StatusCode::SERVICE_UNAVAILABLE,
3709        rhiza_quepaxa::Error::Rejected(_) => StatusCode::CONFLICT,
3710        _ => StatusCode::INTERNAL_SERVER_ERROR,
3711    }
3712}
3713
3714fn fetch_log_error_status(error: &FetchLogError) -> StatusCode {
3715    match error {
3716        FetchLogError::InvalidRequest { .. } => StatusCode::BAD_REQUEST,
3717        FetchLogError::SnapshotRequired { .. } | FetchLogError::Gap { .. } => StatusCode::CONFLICT,
3718        FetchLogError::Decode { .. } | FetchLogError::Transport { .. } => {
3719            StatusCode::SERVICE_UNAVAILABLE
3720        }
3721        FetchLogError::InvalidAnchor { .. }
3722        | FetchLogError::InvalidEntry { .. }
3723        | FetchLogError::ForeignIdentity { .. } => StatusCode::INTERNAL_SERVER_ERROR,
3724    }
3725}
3726
3727fn runtime_readiness_response(runtime: &NodeRuntime) -> Option<Response> {
3728    if runtime.is_fatal() {
3729        Some(client_error_response(
3730            StatusCode::INTERNAL_SERVER_ERROR,
3731            "fatal",
3732            false,
3733            "node is fatally unavailable",
3734            None,
3735        ))
3736    } else if !runtime.is_ready() {
3737        Some(client_error_response(
3738            StatusCode::SERVICE_UNAVAILABLE,
3739            "unavailable",
3740            true,
3741            "node is not ready",
3742            None,
3743        ))
3744    } else {
3745        None
3746    }
3747}
3748
3749#[derive(Debug, serde::Deserialize, serde::Serialize)]
3750#[serde(tag = "status", content = "body")]
3751enum FetchLogHttpResponse {
3752    Fetched(FetchLogResponse),
3753    Failed(FetchLogError),
3754}
3755
3756pub fn catch_up_missing_entries<P: LogPeer + ?Sized>(
3757    local_last_index: LogIndex,
3758    local_last_hash: LogHash,
3759    cluster_id: &str,
3760    epoch: u64,
3761    config_id: u64,
3762    peer: &P,
3763    max_entries: u32,
3764) -> Result<Vec<LogEntry>, FetchLogError> {
3765    if max_entries == 0 {
3766        return Ok(Vec::new());
3767    }
3768    if max_entries > MAX_FETCH_ENTRIES {
3769        return Err(FetchLogError::InvalidRequest {
3770            message: format!("max_entries exceeds {MAX_FETCH_ENTRIES}"),
3771        });
3772    }
3773    if cluster_id.is_empty() {
3774        return Err(FetchLogError::InvalidRequest {
3775            message: "cluster_id must not be empty".into(),
3776        });
3777    }
3778    let from_index =
3779        local_last_index
3780            .checked_add(1)
3781            .ok_or_else(|| FetchLogError::InvalidRequest {
3782                message: "local qlog index is exhausted".into(),
3783            })?;
3784    let response = peer.fetch_log(FetchLogRequest {
3785        from_index,
3786        max_entries,
3787    })?;
3788    if response.entries.len() > max_entries as usize {
3789        return Err(FetchLogError::InvalidRequest {
3790            message: "peer returned more entries than requested".into(),
3791        });
3792    }
3793    if response.last_index < local_last_index {
3794        return Err(FetchLogError::Gap {
3795            expected: local_last_index,
3796            actual: Some(response.last_index),
3797        });
3798    }
3799    if response.entries.is_empty() && response.last_index >= from_index {
3800        return Err(FetchLogError::Gap {
3801            expected: from_index,
3802            actual: None,
3803        });
3804    }
3805    validate_fetched_entries(
3806        from_index,
3807        local_last_hash,
3808        cluster_id,
3809        epoch,
3810        config_id,
3811        response.entries,
3812    )
3813}
3814
3815fn validate_fetched_entries(
3816    from_index: LogIndex,
3817    local_last_hash: LogHash,
3818    cluster_id: &str,
3819    epoch: u64,
3820    config_id: u64,
3821    entries: Vec<LogEntry>,
3822) -> Result<Vec<LogEntry>, FetchLogError> {
3823    validate_fetched_entries_with_configuration(
3824        from_index,
3825        local_last_hash,
3826        cluster_id,
3827        epoch,
3828        ConfigurationState::active(config_id, LogHash::ZERO),
3829        entries,
3830    )
3831}
3832
3833fn validate_fetched_entries_with_configuration(
3834    from_index: LogIndex,
3835    local_last_hash: LogHash,
3836    cluster_id: &str,
3837    epoch: u64,
3838    mut configuration_state: ConfigurationState,
3839    entries: Vec<LogEntry>,
3840) -> Result<Vec<LogEntry>, FetchLogError> {
3841    let mut expected = from_index;
3842    let mut previous_hash = local_last_hash;
3843    for entry in &entries {
3844        if entry.index != expected {
3845            return Err(FetchLogError::Gap {
3846                expected,
3847                actual: Some(entry.index),
3848            });
3849        }
3850        if entry.cluster_id != cluster_id || entry.epoch != epoch {
3851            return Err(FetchLogError::ForeignIdentity { index: entry.index });
3852        }
3853        if entry.prev_hash != previous_hash {
3854            return Err(FetchLogError::InvalidAnchor {
3855                expected: previous_hash,
3856                actual: entry.prev_hash,
3857            });
3858        }
3859        if entry.recompute_hash() != entry.hash {
3860            return Err(FetchLogError::InvalidEntry {
3861                index: entry.index,
3862                message: "hash does not match entry contents".into(),
3863            });
3864        }
3865        validate_entry_shape(entry).map_err(|message| FetchLogError::InvalidEntry {
3866            index: entry.index,
3867            message,
3868        })?;
3869        configuration_state = configuration_state.validate_entry(entry).map_err(|error| {
3870            FetchLogError::InvalidEntry {
3871                index: entry.index,
3872                message: error.to_string(),
3873            }
3874        })?;
3875        expected = expected
3876            .checked_add(1)
3877            .ok_or_else(|| FetchLogError::InvalidEntry {
3878                index: entry.index,
3879                message: "qlog index is exhausted".into(),
3880            })?;
3881        previous_hash = entry.hash;
3882    }
3883    Ok(entries)
3884}
3885
3886#[derive(Clone, Eq, PartialEq)]
3887pub struct PeerConfig {
3888    node_id: String,
3889    base_url: String,
3890    log_base_url: String,
3891    token: String,
3892}
3893
3894impl fmt::Debug for PeerConfig {
3895    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3896        f.debug_struct("PeerConfig")
3897            .field("node_id", &self.node_id)
3898            .field("base_url", &self.base_url)
3899            .field("log_base_url", &self.log_base_url)
3900            .field("token", &"[redacted]")
3901            .finish()
3902    }
3903}
3904
3905impl PeerConfig {
3906    pub fn new(
3907        node_id: impl Into<String>,
3908        base_url: impl Into<String>,
3909        token: impl Into<String>,
3910    ) -> Result<Self, ConfigError> {
3911        let base_url = base_url.into();
3912        Self::new_with_log_url(node_id, base_url.clone(), base_url, token)
3913    }
3914
3915    pub fn new_with_log_url(
3916        node_id: impl Into<String>,
3917        base_url: impl Into<String>,
3918        log_base_url: impl Into<String>,
3919        token: impl Into<String>,
3920    ) -> Result<Self, ConfigError> {
3921        let node_id = node_id.into();
3922        if !valid_nonblank_header_value(&node_id) {
3923            return Err(ConfigError::EmptyPeerNodeId);
3924        }
3925        let base_url = validate_peer_base_url(base_url.into())?;
3926        let log_base_url = validate_peer_base_url(log_base_url.into())?;
3927        let token = token.into();
3928        if !valid_auth_token(&token) {
3929            return Err(ConfigError::EmptyPeerToken);
3930        }
3931        Ok(Self {
3932            node_id,
3933            base_url,
3934            log_base_url,
3935            token,
3936        })
3937    }
3938
3939    pub fn node_id(&self) -> &str {
3940        &self.node_id
3941    }
3942
3943    pub fn base_url(&self) -> &str {
3944        &self.base_url
3945    }
3946
3947    pub fn log_base_url(&self) -> &str {
3948        &self.log_base_url
3949    }
3950
3951    pub fn token(&self) -> &str {
3952        &self.token
3953    }
3954}
3955
3956fn validate_peer_base_url(url: String) -> Result<String, ConfigError> {
3957    let url = url.trim_end_matches('/').to_string();
3958    if url.trim().is_empty() {
3959        return Err(ConfigError::EmptyPeerBaseUrl);
3960    }
3961    let parsed =
3962        reqwest::Url::parse(&url).map_err(|_| ConfigError::InvalidPeerBaseUrl(url.clone()))?;
3963    if !matches!(parsed.scheme(), "http" | "https")
3964        || parsed.host_str().is_none()
3965        || !parsed.username().is_empty()
3966        || parsed.password().is_some()
3967        || parsed.path() != "/"
3968        || parsed.query().is_some()
3969        || parsed.fragment().is_some()
3970    {
3971        return Err(ConfigError::InvalidPeerBaseUrl(url));
3972    }
3973    Ok(url)
3974}
3975
3976pub(crate) fn valid_nonblank_header_value(value: &str) -> bool {
3977    !value.trim().is_empty()
3978        && axum::http::HeaderValue::try_from(value).is_ok_and(|value| value.to_str().is_ok())
3979}
3980
3981pub(crate) fn valid_auth_token(value: &str) -> bool {
3982    valid_nonblank_header_value(value) && !value.chars().any(char::is_whitespace)
3983}
3984
3985#[derive(Clone, Eq, PartialEq)]
3986pub struct NodeConfig {
3987    cluster_id_source: String,
3988    logical_cluster_id: String,
3989    cluster_id: String,
3990    node_id: String,
3991    data_dir: PathBuf,
3992    epoch: u64,
3993    membership: Membership,
3994    log_initial_configuration: ConfigurationState,
3995    configuration_state: ConfigurationState,
3996    predecessor_stop_entry: Option<LogEntry>,
3997    recovery_generation: u64,
3998    peers: Vec<PeerConfig>,
3999    client_token: String,
4000    read_consistency: ReadConsistency,
4001    ack_mode: AckMode,
4002    writer_batch_max: usize,
4003    writer_batch_window: Duration,
4004    execution_profile: ExecutionProfile,
4005    #[cfg(feature = "sql")]
4006    sql_write_profiler: Option<SqlWriteProfiler>,
4007    #[cfg(feature = "sql")]
4008    sql_group_commit_queue_capacity: usize,
4009}
4010
4011impl fmt::Debug for NodeConfig {
4012    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4013        let mut debug = f.debug_struct("NodeConfig");
4014        debug
4015            .field("cluster_id_source", &self.cluster_id_source)
4016            .field("logical_cluster_id", &self.logical_cluster_id)
4017            .field("cluster_id", &self.cluster_id)
4018            .field("node_id", &self.node_id)
4019            .field("data_dir", &self.data_dir)
4020            .field("epoch", &self.epoch)
4021            .field("membership", &self.membership.members())
4022            .field("log_initial_configuration", &self.log_initial_configuration)
4023            .field("configuration_state", &self.configuration_state)
4024            .field(
4025                "predecessor_stop_entry",
4026                &self
4027                    .predecessor_stop_entry
4028                    .as_ref()
4029                    .map(|entry| (entry.index, entry.hash)),
4030            )
4031            .field("recovery_generation", &self.recovery_generation)
4032            .field("peers", &self.peers)
4033            .field("client_token", &"[redacted]")
4034            .field("read_consistency", &self.read_consistency)
4035            .field("ack_mode", &self.ack_mode)
4036            .field("writer_batch_max", &self.writer_batch_max)
4037            .field("writer_batch_window", &self.writer_batch_window)
4038            .field("execution_profile", &self.execution_profile);
4039        #[cfg(feature = "sql")]
4040        debug.field(
4041            "sql_write_profiler",
4042            &self.sql_write_profiler.as_ref().map(|_| "installed"),
4043        );
4044        #[cfg(feature = "sql")]
4045        debug.field(
4046            "sql_group_commit_queue_capacity",
4047            &self.sql_group_commit_queue_capacity,
4048        );
4049        debug.finish()
4050    }
4051}
4052
4053impl NodeConfig {
4054    pub fn new<P>(
4055        cluster_id: impl Into<String>,
4056        node_id: impl Into<String>,
4057        data_dir: PathBuf,
4058        epoch: u64,
4059        config_id: u64,
4060        peers: P,
4061        client_token: impl Into<String>,
4062    ) -> Result<Self, ConfigError>
4063    where
4064        P: Into<Vec<PeerConfig>>,
4065    {
4066        let peers = peers.into();
4067        let membership = membership_from_peers(&peers)?;
4068        let configuration_state = ConfigurationState::active(config_id, membership.digest());
4069        Self::new_with_configuration(
4070            cluster_id,
4071            node_id,
4072            data_dir,
4073            epoch,
4074            membership,
4075            configuration_state,
4076            peers,
4077            client_token,
4078        )
4079    }
4080
4081    pub fn new_embedded<I, S>(
4082        cluster_id: impl Into<String>,
4083        node_id: impl Into<String>,
4084        data_dir: PathBuf,
4085        epoch: u64,
4086        config_id: u64,
4087        members: I,
4088    ) -> Result<Self, ConfigError>
4089    where
4090        I: IntoIterator<Item = S>,
4091        S: Into<String>,
4092    {
4093        let cluster_id = cluster_id.into();
4094        let node_id = node_id.into();
4095        validate_node_identity(&cluster_id, &node_id, &data_dir, epoch, config_id)?;
4096        let membership = membership_from_node_ids(members.into_iter().map(Into::into).collect())?;
4097        if !membership.contains(&node_id) {
4098            return Err(ConfigError::LocalNodeMissing);
4099        }
4100        let configuration_state = ConfigurationState::active(config_id, membership.digest());
4101        Self::from_validated_parts(
4102            cluster_id,
4103            node_id,
4104            data_dir,
4105            epoch,
4106            membership,
4107            configuration_state,
4108            Vec::new(),
4109            String::new(),
4110        )
4111    }
4112
4113    #[allow(clippy::too_many_arguments)]
4114    pub fn new_with_configuration<P>(
4115        cluster_id: impl Into<String>,
4116        node_id: impl Into<String>,
4117        data_dir: PathBuf,
4118        epoch: u64,
4119        membership: Membership,
4120        configuration_state: ConfigurationState,
4121        peers: P,
4122        client_token: impl Into<String>,
4123    ) -> Result<Self, ConfigError>
4124    where
4125        P: Into<Vec<PeerConfig>>,
4126    {
4127        let cluster_id = cluster_id.into();
4128        let node_id = node_id.into();
4129        let client_token = client_token.into();
4130        let peers = peers.into();
4131
4132        validate_node_identity(
4133            &cluster_id,
4134            &node_id,
4135            &data_dir,
4136            epoch,
4137            configuration_state.config_id(),
4138        )?;
4139        if !(3..=7).contains(&peers.len()) {
4140            return Err(ConfigError::InvalidPeerCount(peers.len()));
4141        }
4142        let mut peer_ids = HashSet::with_capacity(peers.len());
4143        let mut peer_tokens = HashSet::with_capacity(peers.len());
4144        for peer in &peers {
4145            if !peer_ids.insert(peer.node_id.clone()) {
4146                return Err(ConfigError::DuplicatePeerNodeId(peer.node_id.clone()));
4147            }
4148            if !peer_tokens.insert(peer.token.as_str()) {
4149                return Err(ConfigError::DuplicatePeerToken);
4150            }
4151        }
4152        if !peer_ids.contains(&node_id) {
4153            return Err(ConfigError::LocalNodeMissing);
4154        }
4155        if peer_ids.len() != membership.members().len()
4156            || membership
4157                .members()
4158                .iter()
4159                .any(|member| !peer_ids.contains(member))
4160        {
4161            return Err(ConfigError::PeerMembershipMismatch);
4162        }
4163        if configuration_state.is_active() && configuration_state.digest() != membership.digest() {
4164            return Err(ConfigError::PeerMembershipMismatch);
4165        }
4166        if !valid_auth_token(&client_token) {
4167            return Err(ConfigError::EmptyClientToken);
4168        }
4169        if peer_tokens.contains(client_token.as_str()) {
4170            return Err(ConfigError::ClientTokenConflictsWithPeer);
4171        }
4172
4173        Self::from_validated_parts(
4174            cluster_id,
4175            node_id,
4176            data_dir,
4177            epoch,
4178            membership,
4179            configuration_state,
4180            peers,
4181            client_token,
4182        )
4183    }
4184
4185    #[allow(clippy::too_many_arguments)]
4186    fn from_validated_parts(
4187        cluster_id: String,
4188        node_id: String,
4189        data_dir: PathBuf,
4190        epoch: u64,
4191        membership: Membership,
4192        configuration_state: ConfigurationState,
4193        peers: Vec<PeerConfig>,
4194        client_token: String,
4195    ) -> Result<Self, ConfigError> {
4196        let log_initial_configuration = ConfigurationState::active(
4197            configuration_state.config_id(),
4198            configuration_state.digest(),
4199        );
4200        let execution_profile =
4201            canonical_cluster_profile(&cluster_id).unwrap_or(ExecutionProfile::Sqlite);
4202        let logical_cluster_id = ["rhiza:sql:", "rhiza:graph:", "rhiza:kv:"]
4203            .into_iter()
4204            .find_map(|prefix| cluster_id.strip_prefix(prefix))
4205            .unwrap_or(&cluster_id)
4206            .to_owned();
4207        let effective_cluster_id = effective_cluster_id(execution_profile, &cluster_id)?;
4208        Ok(Self {
4209            cluster_id_source: cluster_id,
4210            cluster_id: effective_cluster_id,
4211            logical_cluster_id,
4212            node_id,
4213            data_dir,
4214            epoch,
4215            membership,
4216            log_initial_configuration,
4217            configuration_state,
4218            predecessor_stop_entry: None,
4219            recovery_generation: 1,
4220            peers,
4221            client_token,
4222            read_consistency: ReadConsistency::ReadBarrier,
4223            ack_mode: AckMode::HaFirst,
4224            writer_batch_max: DEFAULT_WRITER_BATCH_MAX,
4225            writer_batch_window: DEFAULT_WRITER_BATCH_WINDOW,
4226            execution_profile,
4227            #[cfg(feature = "sql")]
4228            sql_write_profiler: None,
4229            #[cfg(feature = "sql")]
4230            sql_group_commit_queue_capacity: DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY,
4231        })
4232    }
4233
4234    pub fn with_execution_profile(
4235        mut self,
4236        execution_profile: ExecutionProfile,
4237    ) -> Result<Self, ConfigError> {
4238        self.cluster_id = effective_cluster_id(execution_profile, &self.cluster_id_source)?;
4239        self.execution_profile = execution_profile;
4240        Ok(self)
4241    }
4242
4243    pub fn with_read_consistency(mut self, read_consistency: ReadConsistency) -> Self {
4244        self.read_consistency = read_consistency;
4245        self
4246    }
4247
4248    #[cfg(feature = "sql")]
4249    pub fn with_sql_write_profiler(mut self, profiler: SqlWriteProfiler) -> Self {
4250        self.sql_write_profiler = Some(profiler);
4251        self
4252    }
4253
4254    #[cfg(feature = "sql")]
4255    pub fn with_sql_group_commit_queue_capacity(
4256        mut self,
4257        capacity: usize,
4258    ) -> Result<Self, ConfigError> {
4259        if !(1..=MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY).contains(&capacity) {
4260            return Err(ConfigError::InvalidSqlGroupCommitQueueCapacity(capacity));
4261        }
4262        self.sql_group_commit_queue_capacity = capacity;
4263        Ok(self)
4264    }
4265
4266    pub fn with_ack_mode(mut self, ack_mode: AckMode) -> Self {
4267        self.ack_mode = ack_mode;
4268        self
4269    }
4270
4271    pub fn with_writer_batching(
4272        mut self,
4273        max: usize,
4274        window: Duration,
4275    ) -> Result<Self, ConfigError> {
4276        if max == 0 || max > MAX_WRITE_BATCH_MEMBERS {
4277            return Err(ConfigError::InvalidWriterBatchMax(max));
4278        }
4279        if window.is_zero() || window >= CLIENT_WRITE_WAIT_TIMEOUT {
4280            return Err(ConfigError::InvalidWriterBatchWindow);
4281        }
4282        self.writer_batch_max = max;
4283        self.writer_batch_window = window;
4284        Ok(self)
4285    }
4286
4287    pub fn with_log_initial_configuration(mut self, configuration: ConfigurationState) -> Self {
4288        self.log_initial_configuration = configuration;
4289        self
4290    }
4291
4292    pub fn with_predecessor_stop_entry(mut self, entry: LogEntry) -> Self {
4293        self.predecessor_stop_entry = Some(entry);
4294        self
4295    }
4296
4297    /// Binds an unactivated successor draft to the exact predecessor Stop it will adopt.
4298    pub fn bind_predecessor_stop(
4299        mut self,
4300        predecessor_membership: &Membership,
4301        entry: LogEntry,
4302    ) -> Result<Self, ConfigError> {
4303        let target_config_id = self.config_id();
4304        if entry.cluster_id != self.cluster_id
4305            || entry.epoch != self.epoch
4306            || entry.config_id.checked_add(1) != Some(target_config_id)
4307            || entry.recompute_hash() != entry.hash
4308        {
4309            return Err(ConfigError::InvalidPredecessorTransition(
4310                "Stop identity does not match the successor draft".into(),
4311            ));
4312        }
4313        let ConfigChange::BoundStop { successor } =
4314            ConfigChange::recognize_parts(entry.entry_type, &entry.payload).map_err(|_| {
4315                ConfigError::InvalidPredecessorTransition(
4316                    "predecessor entry is not a bound Stop".into(),
4317                )
4318            })?
4319        else {
4320            return Err(ConfigError::InvalidPredecessorTransition(
4321                "predecessor entry is not a bound Stop".into(),
4322            ));
4323        };
4324        if successor.cluster_id() != self.cluster_id
4325            || successor.predecessor_config_id() != entry.config_id
4326            || successor.predecessor_config_digest() != predecessor_membership.digest()
4327            || successor.config_id() != target_config_id
4328            || successor.digest() != self.membership.digest()
4329            || successor.members() != self.membership.members()
4330        {
4331            return Err(ConfigError::InvalidPredecessorTransition(
4332                "Stop successor descriptor does not match the successor draft".into(),
4333            ));
4334        }
4335        let predecessor =
4336            ConfigurationState::active(entry.config_id, predecessor_membership.digest());
4337        let stopped = predecessor
4338            .validate_entry(&entry)
4339            .map_err(|error| ConfigError::InvalidPredecessorTransition(error.to_string()))?;
4340        self.log_initial_configuration = predecessor;
4341        self.configuration_state = stopped;
4342        self.predecessor_stop_entry = Some(entry);
4343        Ok(self)
4344    }
4345
4346    pub fn with_recovery_generation(
4347        mut self,
4348        recovery_generation: u64,
4349    ) -> Result<Self, ConfigError> {
4350        validate_recovery_generation(recovery_generation)?;
4351        self.recovery_generation = recovery_generation;
4352        Ok(self)
4353    }
4354
4355    pub fn cluster_id(&self) -> &str {
4356        &self.cluster_id
4357    }
4358
4359    pub fn logical_cluster_id(&self) -> &str {
4360        &self.logical_cluster_id
4361    }
4362
4363    pub fn node_id(&self) -> &str {
4364        &self.node_id
4365    }
4366
4367    pub fn data_dir(&self) -> &PathBuf {
4368        &self.data_dir
4369    }
4370
4371    pub const fn epoch(&self) -> u64 {
4372        self.epoch
4373    }
4374
4375    pub const fn config_id(&self) -> u64 {
4376        self.configuration_state.config_id()
4377    }
4378
4379    pub const fn recovery_generation(&self) -> u64 {
4380        self.recovery_generation
4381    }
4382
4383    pub fn peers(&self) -> &[PeerConfig] {
4384        &self.peers
4385    }
4386
4387    pub const fn membership(&self) -> &Membership {
4388        &self.membership
4389    }
4390
4391    pub const fn configuration_state(&self) -> &ConfigurationState {
4392        &self.configuration_state
4393    }
4394
4395    pub const fn log_initial_configuration(&self) -> &ConfigurationState {
4396        &self.log_initial_configuration
4397    }
4398
4399    pub fn client_token(&self) -> &str {
4400        &self.client_token
4401    }
4402
4403    pub const fn read_consistency(&self) -> ReadConsistency {
4404        self.read_consistency
4405    }
4406
4407    pub const fn ack_mode(&self) -> AckMode {
4408        self.ack_mode
4409    }
4410
4411    pub const fn writer_batch_max(&self) -> usize {
4412        self.writer_batch_max
4413    }
4414
4415    pub const fn writer_batch_window(&self) -> Duration {
4416        self.writer_batch_window
4417    }
4418
4419    pub const fn execution_profile(&self) -> ExecutionProfile {
4420        self.execution_profile
4421    }
4422
4423    #[cfg(feature = "sql")]
4424    pub const fn sql_write_profiler(&self) -> Option<&SqlWriteProfiler> {
4425        self.sql_write_profiler.as_ref()
4426    }
4427
4428    #[cfg(feature = "sql")]
4429    pub const fn sql_group_commit_queue_capacity(&self) -> usize {
4430        self.sql_group_commit_queue_capacity
4431    }
4432}
4433
4434fn validate_node_identity(
4435    cluster_id: &str,
4436    node_id: &str,
4437    data_dir: &Path,
4438    epoch: u64,
4439    config_id: u64,
4440) -> Result<(), ConfigError> {
4441    if cluster_id.trim().is_empty() {
4442        return Err(ConfigError::EmptyClusterId);
4443    }
4444    if node_id.trim().is_empty() {
4445        return Err(ConfigError::EmptyNodeId);
4446    }
4447    if data_dir.as_os_str().is_empty() {
4448        return Err(ConfigError::EmptyDataDir);
4449    }
4450    if epoch == 0 {
4451        return Err(ConfigError::InvalidEpoch);
4452    }
4453    if config_id == 0 {
4454        return Err(ConfigError::InvalidConfigId);
4455    }
4456    Ok(())
4457}
4458
4459fn membership_from_node_ids(members: Vec<String>) -> Result<Membership, ConfigError> {
4460    if !(3..=7).contains(&members.len()) {
4461        return Err(ConfigError::InvalidPeerCount(members.len()));
4462    }
4463    Membership::from_voters(members.clone()).map_err(|error| match error {
4464        rhiza_quepaxa::Error::DuplicateRecorderIdentity => {
4465            let duplicate = members
4466                .iter()
4467                .find(|candidate| {
4468                    members
4469                        .iter()
4470                        .filter(|member| *member == *candidate)
4471                        .count()
4472                        > 1
4473                })
4474                .cloned()
4475                .unwrap_or_default();
4476            ConfigError::DuplicatePeerNodeId(duplicate)
4477        }
4478        rhiza_quepaxa::Error::EmptyRecorderIdentity => ConfigError::EmptyPeerNodeId,
4479        _ => ConfigError::InvalidPeerCount(members.len()),
4480    })
4481}
4482
4483fn membership_from_peers(peers: &[PeerConfig]) -> Result<Membership, ConfigError> {
4484    membership_from_node_ids(peers.iter().map(|peer| peer.node_id.clone()).collect())
4485}
4486
4487#[derive(Clone, Debug, Eq, PartialEq)]
4488pub enum NodeError {
4489    UnsupportedAckMode(AckMode),
4490    ExecutionProfileMismatch {
4491        expected: ExecutionProfile,
4492        actual: ExecutionProfile,
4493    },
4494    DataRootLocked(PathBuf),
4495    SnapshotRequired(Box<RecoveryAnchor>),
4496    Storage(String),
4497    Reconciliation(String),
4498    Invariant(String),
4499    Unavailable(String),
4500    ResourceExhausted(String),
4501    ConfigurationTransition {
4502        state: Box<ConfigurationState>,
4503    },
4504    Contention(String),
4505    WinnerLimitExceeded,
4506    #[cfg(feature = "sql")]
4507    RequestConflict(RequestConflict),
4508    InvalidRequest(String),
4509    #[cfg(feature = "sql")]
4510    InvalidSqlStatement {
4511        statement_index: usize,
4512        message: String,
4513    },
4514    PreconditionFailed(String),
4515    Fatal(String),
4516}
4517
4518impl fmt::Display for NodeError {
4519    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4520        match self {
4521            Self::UnsupportedAckMode(mode) => {
4522                write!(
4523                    f,
4524                    "ack mode {mode:?} is unsupported without synchronous archive"
4525                )
4526            }
4527            Self::ExecutionProfileMismatch { expected, actual } => write!(
4528                f,
4529                "execution profile mismatch: expected {expected}, got {actual}"
4530            ),
4531            Self::DataRootLocked(path) => {
4532                write!(f, "node data root is already owned: {}", path.display())
4533            }
4534            Self::SnapshotRequired(anchor) => write!(
4535                f,
4536                "snapshot restore required at qlog anchor {}",
4537                anchor.compacted().index()
4538            ),
4539            Self::Storage(message) => write!(f, "node storage failed: {message}"),
4540            Self::Reconciliation(message) => write!(f, "node reconciliation failed: {message}"),
4541            Self::Invariant(message) => write!(f, "node invariant failed: {message}"),
4542            Self::Unavailable(message) => write!(f, "node unavailable: {message}"),
4543            Self::ResourceExhausted(message) => {
4544                write!(f, "node query resources exhausted: {message}")
4545            }
4546            Self::ConfigurationTransition { state } => write!(
4547                f,
4548                "node unavailable during configuration transition: {state:?}"
4549            ),
4550            Self::Contention(message) => write!(f, "node contention: {message}"),
4551            Self::WinnerLimitExceeded => write!(f, "foreign winner retry limit exceeded"),
4552            #[cfg(feature = "sql")]
4553            Self::RequestConflict(conflict) => conflict.fmt(f),
4554            Self::InvalidRequest(message) => write!(f, "invalid request: {message}"),
4555            #[cfg(feature = "sql")]
4556            Self::InvalidSqlStatement {
4557                statement_index,
4558                message,
4559            } => write!(
4560                f,
4561                "invalid SQL statement at index {statement_index}: {message}"
4562            ),
4563            Self::PreconditionFailed(message) => write!(f, "precondition failed: {message}"),
4564            Self::Fatal(message) => write!(f, "node is fatally unavailable: {message}"),
4565        }
4566    }
4567}
4568
4569impl std::error::Error for NodeError {}
4570
4571impl NodeError {
4572    pub fn classification(&self) -> ErrorClassification {
4573        let (code, retryable) = match self {
4574            Self::InvalidRequest(_) => ("invalid_request", false),
4575            #[cfg(feature = "sql")]
4576            Self::InvalidSqlStatement { .. } => ("invalid_request", false),
4577            #[cfg(feature = "sql")]
4578            Self::RequestConflict(_) => ("request_conflict", false),
4579            Self::PreconditionFailed(_) => ("precondition_failed", false),
4580            Self::SnapshotRequired(_) => ("snapshot_required", false),
4581            Self::Unavailable(_) => ("unavailable", true),
4582            Self::ResourceExhausted(_) => ("resource_exhausted", true),
4583            Self::ConfigurationTransition { .. } => ("configuration_transition", true),
4584            Self::Contention(_) => ("contention", true),
4585            Self::WinnerLimitExceeded => ("winner_limit_exceeded", true),
4586            Self::DataRootLocked(_) => ("data_root_locked", false),
4587            Self::UnsupportedAckMode(_) => ("unsupported_ack_mode", false),
4588            Self::ExecutionProfileMismatch { .. } => ("execution_profile_mismatch", false),
4589            Self::Storage(_) => ("storage_error", false),
4590            Self::Reconciliation(_) => ("reconciliation_error", false),
4591            Self::Invariant(_) => ("invariant_violation", false),
4592            Self::Fatal(_) => ("fatal", false),
4593        };
4594        ErrorClassification::from_server_code(code, retryable)
4595    }
4596}
4597
4598pub type RuntimeError = NodeError;
4599
4600#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4601#[serde(rename_all = "snake_case")]
4602pub enum RuntimeConfigurationStatus {
4603    Active,
4604    Stopped,
4605    AwaitingActivation,
4606}
4607
4608#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4609pub struct NodeStatus {
4610    pub ready: bool,
4611    pub configuration_status: RuntimeConfigurationStatus,
4612    pub configuration_state: ConfigurationState,
4613    pub stop_anchor: Option<rhiza_core::LogAnchor>,
4614    pub active_config_id: u64,
4615    pub active_membership_digest: LogHash,
4616}
4617
4618#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4619#[serde(deny_unknown_fields)]
4620pub struct StopInformation {
4621    pub entry: LogEntry,
4622    pub proof: DecisionProof,
4623}
4624
4625#[cfg(feature = "sql")]
4626#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4627#[serde(deny_unknown_fields)]
4628pub struct WriteRequest {
4629    pub request_id: String,
4630    pub key: String,
4631    pub value: String,
4632}
4633
4634#[cfg(feature = "sql")]
4635#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4636pub struct WriteResponse {
4637    pub applied_index: LogIndex,
4638    pub hash: LogHash,
4639}
4640
4641#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4642#[serde(deny_unknown_fields)]
4643pub struct ClientErrorResponse {
4644    pub code: String,
4645    pub retryable: bool,
4646    pub message: String,
4647    #[serde(skip_serializing_if = "Option::is_none")]
4648    pub statement_index: Option<usize>,
4649}
4650
4651#[cfg(feature = "sql")]
4652#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4653#[serde(deny_unknown_fields)]
4654pub struct ReadRequest {
4655    pub key: String,
4656    pub consistency: Option<ReadConsistency>,
4657}
4658
4659#[cfg(feature = "sql")]
4660#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4661pub struct ReadResponse {
4662    pub value: Option<String>,
4663    pub applied_index: LogIndex,
4664    pub hash: LogHash,
4665}
4666
4667#[cfg(feature = "sql")]
4668#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4669#[serde(deny_unknown_fields)]
4670pub struct SqlExecuteRequest {
4671    pub request_id: String,
4672    pub statements: Vec<SqlStatement>,
4673}
4674
4675#[cfg(feature = "sql")]
4676#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4677#[serde(deny_unknown_fields)]
4678pub struct SqlExecuteResponse {
4679    pub applied_index: LogIndex,
4680    pub hash: LogHash,
4681    pub results: Vec<SqlStatementResult>,
4682}
4683
4684#[cfg(feature = "sql")]
4685impl From<WriteResponse> for SqlExecuteResponse {
4686    fn from(response: WriteResponse) -> Self {
4687        sql_execute_response(response, None)
4688    }
4689}
4690
4691#[cfg(feature = "sql")]
4692fn sql_execute_response(
4693    response: WriteResponse,
4694    result: Option<SqlCommandResult>,
4695) -> SqlExecuteResponse {
4696    let results = result
4697        .map(|result| {
4698            result
4699                .statement_results
4700                .into_iter()
4701                .enumerate()
4702                .map(|(statement_index, result)| SqlStatementResult {
4703                    statement_index,
4704                    rows_affected: result.rows_affected,
4705                    returning: result.returning,
4706                })
4707                .collect()
4708        })
4709        .unwrap_or_default();
4710    SqlExecuteResponse {
4711        applied_index: response.applied_index,
4712        hash: response.hash,
4713        results,
4714    }
4715}
4716
4717#[cfg(feature = "sql")]
4718#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4719pub struct SqlStatementResult {
4720    pub statement_index: usize,
4721    pub rows_affected: u64,
4722    #[serde(skip_serializing_if = "Option::is_none")]
4723    pub returning: Option<SqlQueryResult>,
4724}
4725
4726#[cfg(feature = "sql")]
4727#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4728#[serde(deny_unknown_fields)]
4729pub struct SqlQueryRequest {
4730    pub statement: SqlStatement,
4731    pub consistency: Option<ReadConsistency>,
4732    pub max_rows: Option<u32>,
4733}
4734
4735#[cfg(feature = "sql")]
4736#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4737pub struct SqlQueryResponse {
4738    pub columns: Vec<String>,
4739    pub rows: Vec<Vec<SqlValue>>,
4740    pub applied_index: LogIndex,
4741    pub hash: LogHash,
4742}
4743
4744#[cfg(feature = "graph")]
4745#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4746#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4747pub enum GraphValueDto {
4748    Null,
4749    Bool(bool),
4750    I64(i64),
4751    U64(u64),
4752    F64(f64),
4753    String(String),
4754    Bytes(String),
4755}
4756
4757#[cfg(feature = "graph")]
4758#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4759#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4760pub enum GraphQueryParameterDto {
4761    Null,
4762    Bool(bool),
4763    I64(i64),
4764    U64(u64),
4765    F64(f64),
4766    String(String),
4767    Bytes(String),
4768    List(Vec<Self>),
4769    Struct(BTreeMap<String, Self>),
4770}
4771
4772#[cfg(feature = "graph")]
4773impl TryFrom<GraphQueryParameterDto> for GraphParameterValue {
4774    type Error = NodeError;
4775
4776    fn try_from(value: GraphQueryParameterDto) -> Result<Self, Self::Error> {
4777        Ok(match value {
4778            GraphQueryParameterDto::Null => Self::Null,
4779            GraphQueryParameterDto::Bool(value) => Self::Bool(value),
4780            GraphQueryParameterDto::I64(value) => Self::I64(value),
4781            GraphQueryParameterDto::U64(value) => Self::U64(value),
4782            GraphQueryParameterDto::F64(value) => Self::F64(
4783                CanonicalF64::new(value)
4784                    .map_err(|error| NodeError::InvalidRequest(error.to_string()))?,
4785            ),
4786            GraphQueryParameterDto::String(value) => Self::String(value),
4787            GraphQueryParameterDto::Bytes(value) => {
4788                Self::Bytes(decode_base64("graph parameter bytes", &value)?)
4789            }
4790            GraphQueryParameterDto::List(values) => Self::List(
4791                values
4792                    .into_iter()
4793                    .map(Self::try_from)
4794                    .collect::<Result<_, _>>()?,
4795            ),
4796            GraphQueryParameterDto::Struct(values) => Self::Struct(
4797                values
4798                    .into_iter()
4799                    .map(|(name, value)| Self::try_from(value).map(|value| (name, value)))
4800                    .collect::<Result<_, _>>()?,
4801            ),
4802        })
4803    }
4804}
4805
4806#[cfg(feature = "graph")]
4807#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4808#[serde(deny_unknown_fields)]
4809pub struct GraphQueryStatementDto {
4810    pub cypher: String,
4811    #[serde(default)]
4812    pub parameters: BTreeMap<String, GraphQueryParameterDto>,
4813}
4814
4815#[cfg(feature = "graph")]
4816#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4817#[serde(deny_unknown_fields)]
4818pub struct GraphQueryRequest {
4819    pub statement: GraphQueryStatementDto,
4820    pub consistency: Option<ReadConsistency>,
4821    pub max_rows: Option<u32>,
4822}
4823
4824#[cfg(feature = "graph")]
4825#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4826pub struct GraphInternalIdDto {
4827    pub offset: u64,
4828    pub table_id: u64,
4829}
4830
4831#[cfg(feature = "graph")]
4832impl From<GraphInternalId> for GraphInternalIdDto {
4833    fn from(value: GraphInternalId) -> Self {
4834        Self {
4835            offset: value.offset,
4836            table_id: value.table_id,
4837        }
4838    }
4839}
4840
4841#[cfg(feature = "graph")]
4842#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4843pub struct GraphNamedValueDto {
4844    pub name: String,
4845    pub value: GraphResultValueDto,
4846}
4847
4848#[cfg(feature = "graph")]
4849#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4850pub struct GraphNodeDto {
4851    pub id: GraphInternalIdDto,
4852    pub label: String,
4853    pub properties: Vec<GraphNamedValueDto>,
4854}
4855
4856#[cfg(feature = "graph")]
4857impl From<GraphNode> for GraphNodeDto {
4858    fn from(value: GraphNode) -> Self {
4859        Self {
4860            id: value.id.into(),
4861            label: value.label,
4862            properties: named_graph_values(value.properties),
4863        }
4864    }
4865}
4866
4867#[cfg(feature = "graph")]
4868#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4869pub struct GraphRelDto {
4870    pub src: GraphInternalIdDto,
4871    pub dst: GraphInternalIdDto,
4872    pub label: String,
4873    pub properties: Vec<GraphNamedValueDto>,
4874}
4875
4876#[cfg(feature = "graph")]
4877impl From<GraphRel> for GraphRelDto {
4878    fn from(value: GraphRel) -> Self {
4879        Self {
4880            src: value.src.into(),
4881            dst: value.dst.into(),
4882            label: value.label,
4883            properties: named_graph_values(value.properties),
4884        }
4885    }
4886}
4887
4888#[cfg(feature = "graph")]
4889#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4890pub struct GraphRecursiveRelDto {
4891    pub nodes: Vec<GraphNodeDto>,
4892    pub rels: Vec<GraphRelDto>,
4893}
4894
4895#[cfg(feature = "graph")]
4896#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4897pub struct GraphMapEntryDto {
4898    pub key: GraphResultValueDto,
4899    pub value: GraphResultValueDto,
4900}
4901
4902#[cfg(feature = "graph")]
4903#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4904pub struct GraphNamedLogicalTypeDto {
4905    pub name: String,
4906    pub logical_type: GraphLogicalTypeDto,
4907}
4908
4909#[cfg(feature = "graph")]
4910#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4911pub struct GraphArrayTypeDto {
4912    pub element_type: Box<GraphLogicalTypeDto>,
4913    pub length: u64,
4914}
4915
4916#[cfg(feature = "graph")]
4917#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4918pub struct GraphMapTypeDto {
4919    pub key_type: Box<GraphLogicalTypeDto>,
4920    pub value_type: Box<GraphLogicalTypeDto>,
4921}
4922
4923#[cfg(feature = "graph")]
4924#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4925pub struct GraphDecimalTypeDto {
4926    pub precision: u32,
4927    pub scale: u32,
4928}
4929
4930#[cfg(feature = "graph")]
4931#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4932#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4933pub enum GraphLogicalTypeDto {
4934    Any,
4935    Bool,
4936    Serial,
4937    I64,
4938    I32,
4939    I16,
4940    I8,
4941    U64,
4942    U32,
4943    U16,
4944    U8,
4945    I128,
4946    F64,
4947    F32,
4948    Date,
4949    Interval,
4950    Timestamp,
4951    TimestampTz,
4952    TimestampNs,
4953    TimestampMs,
4954    TimestampSec,
4955    InternalId,
4956    String,
4957    Json,
4958    Bytes,
4959    List(Box<Self>),
4960    Array(GraphArrayTypeDto),
4961    Struct(Vec<GraphNamedLogicalTypeDto>),
4962    Node,
4963    Rel,
4964    RecursiveRel,
4965    Map(GraphMapTypeDto),
4966    Union(Vec<GraphNamedLogicalTypeDto>),
4967    Uuid,
4968    Decimal(GraphDecimalTypeDto),
4969}
4970
4971#[cfg(feature = "graph")]
4972impl From<GraphLogicalType> for GraphLogicalTypeDto {
4973    fn from(value: GraphLogicalType) -> Self {
4974        match value {
4975            GraphLogicalType::Any => Self::Any,
4976            GraphLogicalType::Bool => Self::Bool,
4977            GraphLogicalType::Serial => Self::Serial,
4978            GraphLogicalType::I64 => Self::I64,
4979            GraphLogicalType::I32 => Self::I32,
4980            GraphLogicalType::I16 => Self::I16,
4981            GraphLogicalType::I8 => Self::I8,
4982            GraphLogicalType::U64 => Self::U64,
4983            GraphLogicalType::U32 => Self::U32,
4984            GraphLogicalType::U16 => Self::U16,
4985            GraphLogicalType::U8 => Self::U8,
4986            GraphLogicalType::I128 => Self::I128,
4987            GraphLogicalType::F64 => Self::F64,
4988            GraphLogicalType::F32 => Self::F32,
4989            GraphLogicalType::Date => Self::Date,
4990            GraphLogicalType::Interval => Self::Interval,
4991            GraphLogicalType::Timestamp => Self::Timestamp,
4992            GraphLogicalType::TimestampTz => Self::TimestampTz,
4993            GraphLogicalType::TimestampNs => Self::TimestampNs,
4994            GraphLogicalType::TimestampMs => Self::TimestampMs,
4995            GraphLogicalType::TimestampSec => Self::TimestampSec,
4996            GraphLogicalType::InternalId => Self::InternalId,
4997            GraphLogicalType::String => Self::String,
4998            GraphLogicalType::Json => Self::Json,
4999            GraphLogicalType::Bytes => Self::Bytes,
5000            GraphLogicalType::List(element_type) => Self::List(Box::new((*element_type).into())),
5001            GraphLogicalType::Array {
5002                element_type,
5003                length,
5004            } => Self::Array(GraphArrayTypeDto {
5005                element_type: Box::new((*element_type).into()),
5006                length,
5007            }),
5008            GraphLogicalType::Struct(fields) => Self::Struct(named_graph_logical_types(fields)),
5009            GraphLogicalType::Node => Self::Node,
5010            GraphLogicalType::Rel => Self::Rel,
5011            GraphLogicalType::RecursiveRel => Self::RecursiveRel,
5012            GraphLogicalType::Map {
5013                key_type,
5014                value_type,
5015            } => Self::Map(GraphMapTypeDto {
5016                key_type: Box::new((*key_type).into()),
5017                value_type: Box::new((*value_type).into()),
5018            }),
5019            GraphLogicalType::Union(types) => Self::Union(named_graph_logical_types(types)),
5020            GraphLogicalType::Uuid => Self::Uuid,
5021            GraphLogicalType::Decimal { precision, scale } => {
5022                Self::Decimal(GraphDecimalTypeDto { precision, scale })
5023            }
5024        }
5025    }
5026}
5027
5028#[cfg(feature = "graph")]
5029#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5030pub struct GraphCollectionValueDto {
5031    pub element_type: GraphLogicalTypeDto,
5032    pub values: Vec<GraphResultValueDto>,
5033}
5034
5035#[cfg(feature = "graph")]
5036#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5037pub struct GraphMapValueDto {
5038    pub key_type: GraphLogicalTypeDto,
5039    pub value_type: GraphLogicalTypeDto,
5040    pub entries: Vec<GraphMapEntryDto>,
5041}
5042
5043#[cfg(feature = "graph")]
5044#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5045pub struct GraphUnionValueDto {
5046    pub variants: Vec<GraphNamedLogicalTypeDto>,
5047    pub value: Box<GraphResultValueDto>,
5048}
5049
5050#[cfg(feature = "graph")]
5051#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5052#[serde(tag = "type", content = "value", rename_all = "snake_case")]
5053pub enum GraphResultValueDto {
5054    Null(GraphLogicalTypeDto),
5055    Bool(bool),
5056    I64(i64),
5057    I32(i32),
5058    I16(i16),
5059    I8(i8),
5060    U64(u64),
5061    U32(u32),
5062    U16(u16),
5063    U8(u8),
5064    I128(String),
5065    F64(f64),
5066    F32(String),
5067    Date(String),
5068    Interval(String),
5069    Timestamp(String),
5070    TimestampTz(String),
5071    TimestampNs(String),
5072    TimestampMs(String),
5073    TimestampSec(String),
5074    InternalId(GraphInternalIdDto),
5075    String(String),
5076    Json(String),
5077    Bytes(String),
5078    List(GraphCollectionValueDto),
5079    Array(GraphCollectionValueDto),
5080    Struct(Vec<GraphNamedValueDto>),
5081    Node(GraphNodeDto),
5082    Rel(GraphRelDto),
5083    RecursiveRel(GraphRecursiveRelDto),
5084    Map(GraphMapValueDto),
5085    Union(GraphUnionValueDto),
5086    Uuid(String),
5087    Decimal(String),
5088}
5089
5090#[cfg(feature = "graph")]
5091impl From<GraphResultValue> for GraphResultValueDto {
5092    fn from(value: GraphResultValue) -> Self {
5093        match value {
5094            GraphResultValue::Null(value) => Self::Null(value.into()),
5095            GraphResultValue::Bool(value) => Self::Bool(value),
5096            GraphResultValue::I64(value) => Self::I64(value),
5097            GraphResultValue::I32(value) => Self::I32(value),
5098            GraphResultValue::I16(value) => Self::I16(value),
5099            GraphResultValue::I8(value) => Self::I8(value),
5100            GraphResultValue::U64(value) => Self::U64(value),
5101            GraphResultValue::U32(value) => Self::U32(value),
5102            GraphResultValue::U16(value) => Self::U16(value),
5103            GraphResultValue::U8(value) => Self::U8(value),
5104            GraphResultValue::I128(value) => Self::I128(value),
5105            GraphResultValue::F64(value) => Self::F64(value.get()),
5106            GraphResultValue::F32(value) => Self::F32(value),
5107            GraphResultValue::Date(value) => Self::Date(value),
5108            GraphResultValue::Interval(value) => Self::Interval(value),
5109            GraphResultValue::Timestamp(value) => Self::Timestamp(value),
5110            GraphResultValue::TimestampTz(value) => Self::TimestampTz(value),
5111            GraphResultValue::TimestampNs(value) => Self::TimestampNs(value),
5112            GraphResultValue::TimestampMs(value) => Self::TimestampMs(value),
5113            GraphResultValue::TimestampSec(value) => Self::TimestampSec(value),
5114            GraphResultValue::InternalId(value) => Self::InternalId(value.into()),
5115            GraphResultValue::String(value) => Self::String(value),
5116            GraphResultValue::Json(value) => Self::Json(value),
5117            GraphResultValue::Bytes(value) => Self::Bytes(encode_base64(&value)),
5118            GraphResultValue::List {
5119                element_type,
5120                values,
5121            } => Self::List(GraphCollectionValueDto {
5122                element_type: element_type.into(),
5123                values: values.into_iter().map(Self::from).collect(),
5124            }),
5125            GraphResultValue::Array {
5126                element_type,
5127                values,
5128            } => Self::Array(GraphCollectionValueDto {
5129                element_type: element_type.into(),
5130                values: values.into_iter().map(Self::from).collect(),
5131            }),
5132            GraphResultValue::Struct(values) => Self::Struct(named_graph_values(values)),
5133            GraphResultValue::Node(value) => Self::Node(value.into()),
5134            GraphResultValue::Rel(value) => Self::Rel(value.into()),
5135            GraphResultValue::RecursiveRel { nodes, rels } => {
5136                Self::RecursiveRel(GraphRecursiveRelDto {
5137                    nodes: nodes.into_iter().map(Into::into).collect(),
5138                    rels: rels.into_iter().map(Into::into).collect(),
5139                })
5140            }
5141            GraphResultValue::Map {
5142                key_type,
5143                value_type,
5144                entries,
5145            } => Self::Map(GraphMapValueDto {
5146                key_type: key_type.into(),
5147                value_type: value_type.into(),
5148                entries: entries
5149                    .into_iter()
5150                    .map(|(key, value)| GraphMapEntryDto {
5151                        key: key.into(),
5152                        value: value.into(),
5153                    })
5154                    .collect(),
5155            }),
5156            GraphResultValue::Union { variants, value } => Self::Union(GraphUnionValueDto {
5157                variants: named_graph_logical_types(variants),
5158                value: Box::new(Self::from(*value)),
5159            }),
5160            GraphResultValue::Uuid(value) => Self::Uuid(value),
5161            GraphResultValue::Decimal(value) => Self::Decimal(value),
5162        }
5163    }
5164}
5165
5166#[cfg(feature = "graph")]
5167fn named_graph_values(values: Vec<(String, GraphResultValue)>) -> Vec<GraphNamedValueDto> {
5168    values
5169        .into_iter()
5170        .map(|(name, value)| GraphNamedValueDto {
5171            name,
5172            value: value.into(),
5173        })
5174        .collect()
5175}
5176
5177#[cfg(feature = "graph")]
5178fn named_graph_logical_types(
5179    values: Vec<(String, GraphLogicalType)>,
5180) -> Vec<GraphNamedLogicalTypeDto> {
5181    values
5182        .into_iter()
5183        .map(|(name, logical_type)| GraphNamedLogicalTypeDto {
5184            name,
5185            logical_type: logical_type.into(),
5186        })
5187        .collect()
5188}
5189
5190#[cfg(feature = "graph")]
5191#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5192pub struct GraphColumnDto {
5193    pub name: String,
5194    pub logical_type: GraphLogicalTypeDto,
5195}
5196
5197#[cfg(feature = "graph")]
5198impl From<GraphColumn> for GraphColumnDto {
5199    fn from(value: GraphColumn) -> Self {
5200        Self {
5201            name: value.name,
5202            logical_type: value.logical_type.into(),
5203        }
5204    }
5205}
5206
5207#[cfg(feature = "graph")]
5208#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5209pub struct GraphQueryResponse {
5210    pub columns: Vec<GraphColumnDto>,
5211    pub rows: Vec<Vec<GraphResultValueDto>>,
5212    pub applied_index: LogIndex,
5213    pub hash: LogHash,
5214}
5215
5216#[cfg(feature = "graph")]
5217impl From<GraphQueryResult> for GraphQueryResponse {
5218    fn from(value: GraphQueryResult) -> Self {
5219        Self {
5220            columns: value.columns.into_iter().map(Into::into).collect(),
5221            rows: value
5222                .rows
5223                .into_iter()
5224                .map(|row| row.into_iter().map(Into::into).collect())
5225                .collect(),
5226            applied_index: value.applied_index,
5227            hash: value.hash,
5228        }
5229    }
5230}
5231
5232#[cfg(feature = "graph")]
5233#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5234#[serde(deny_unknown_fields)]
5235pub struct GraphPutDocumentRequest {
5236    pub request_id: String,
5237    pub id: String,
5238    pub value: GraphValueDto,
5239}
5240
5241#[cfg(feature = "graph")]
5242#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5243#[serde(deny_unknown_fields)]
5244pub struct GraphDeleteDocumentRequest {
5245    pub request_id: String,
5246    pub id: String,
5247}
5248
5249#[cfg(feature = "graph")]
5250#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5251#[serde(deny_unknown_fields)]
5252pub struct GraphGetDocumentRequest {
5253    pub id: String,
5254    pub consistency: Option<ReadConsistency>,
5255}
5256
5257#[cfg(feature = "graph")]
5258#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5259#[serde(tag = "operation", rename_all = "snake_case")]
5260pub enum GraphMutationResultDto {
5261    PutDocument { created: bool },
5262    DeleteDocument { existed: bool },
5263}
5264
5265#[cfg(feature = "graph")]
5266#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5267pub struct GraphMutationResponse {
5268    pub applied_index: LogIndex,
5269    pub hash: LogHash,
5270    pub result: GraphMutationResultDto,
5271}
5272
5273#[cfg(feature = "graph")]
5274#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5275pub struct GraphGetDocumentResponse {
5276    pub value: Option<GraphValueDto>,
5277    pub applied_index: LogIndex,
5278    pub hash: LogHash,
5279}
5280
5281#[cfg(feature = "kv")]
5282#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5283#[serde(deny_unknown_fields)]
5284pub struct KvPutRequest {
5285    pub request_id: String,
5286    pub key: String,
5287    pub value: String,
5288}
5289
5290#[cfg(feature = "kv")]
5291#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5292#[serde(deny_unknown_fields)]
5293pub struct KvDeleteRequest {
5294    pub request_id: String,
5295    pub key: String,
5296}
5297
5298#[cfg(feature = "kv")]
5299#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5300#[serde(deny_unknown_fields)]
5301pub struct KvGetRequest {
5302    pub key: String,
5303    pub consistency: Option<ReadConsistency>,
5304}
5305
5306#[cfg(feature = "kv")]
5307#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5308#[serde(deny_unknown_fields)]
5309pub struct KvScanRequest {
5310    pub start: Option<String>,
5311    pub end: Option<String>,
5312    pub prefix: Option<String>,
5313    pub cursor: Option<String>,
5314    pub limit: Option<usize>,
5315    pub consistency: Option<ReadConsistency>,
5316}
5317
5318#[cfg(feature = "kv")]
5319#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5320#[serde(tag = "operation", rename_all = "snake_case")]
5321pub enum KvMutationResultDto {
5322    Put { replaced: bool },
5323    Delete { existed: bool },
5324}
5325
5326#[cfg(feature = "kv")]
5327#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5328pub struct KvMutationResponse {
5329    pub applied_index: LogIndex,
5330    pub hash: LogHash,
5331    pub result: KvMutationResultDto,
5332}
5333
5334#[cfg(feature = "kv")]
5335#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5336pub struct KvGetResponse {
5337    pub value: Option<String>,
5338    pub applied_index: LogIndex,
5339    pub hash: LogHash,
5340}
5341
5342#[cfg(feature = "kv")]
5343#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5344pub struct KvScanEntryDto {
5345    pub key: String,
5346    pub value: String,
5347}
5348
5349#[cfg(feature = "kv")]
5350#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5351pub struct KvScanResponse {
5352    pub entries: Vec<KvScanEntryDto>,
5353    pub next_cursor: Option<String>,
5354    pub applied_index: LogIndex,
5355    pub hash: LogHash,
5356}
5357
5358#[cfg(feature = "graph")]
5359fn graph_mutation_response(outcome: GraphMutationOutcome) -> GraphMutationResponse {
5360    GraphMutationResponse {
5361        applied_index: outcome.applied_index(),
5362        hash: outcome.hash(),
5363        result: match outcome.result() {
5364            GraphCommandResultV1::PutDocument { created } => {
5365                GraphMutationResultDto::PutDocument { created: *created }
5366            }
5367            GraphCommandResultV1::DeleteDocument { existed } => {
5368                GraphMutationResultDto::DeleteDocument { existed: *existed }
5369            }
5370        },
5371    }
5372}
5373
5374#[cfg(feature = "kv")]
5375fn kv_mutation_response(outcome: KvMutationOutcome) -> KvMutationResponse {
5376    KvMutationResponse {
5377        applied_index: outcome.applied_index(),
5378        hash: outcome.hash(),
5379        result: match outcome.result() {
5380            KvCommandResultV1::Put { replaced } => KvMutationResultDto::Put {
5381                replaced: *replaced,
5382            },
5383            KvCommandResultV1::Delete { existed } => {
5384                KvMutationResultDto::Delete { existed: *existed }
5385            }
5386        },
5387    }
5388}
5389
5390#[cfg(feature = "kv")]
5391fn validate_kv_scan_required_index(
5392    result: &KvScanResult,
5393    required_index: Option<LogIndex>,
5394) -> Result<(), NodeError> {
5395    let applied_index = result.tip().applied_index();
5396    if required_index.is_some_and(|required| applied_index < required) {
5397        return Err(NodeError::Unavailable(format!(
5398            "local applied index {applied_index} has not reached {}",
5399            required_index.expect("checked above")
5400        )));
5401    }
5402    Ok(())
5403}
5404
5405#[cfg(feature = "graph")]
5406impl TryFrom<GraphValueDto> for GraphValueV1 {
5407    type Error = NodeError;
5408
5409    fn try_from(value: GraphValueDto) -> Result<Self, Self::Error> {
5410        match value {
5411            GraphValueDto::Null => Ok(Self::Null),
5412            GraphValueDto::Bool(value) => Ok(Self::Bool(value)),
5413            GraphValueDto::I64(value) => Ok(Self::I64(value)),
5414            GraphValueDto::U64(value) => Ok(Self::U64(value)),
5415            GraphValueDto::F64(value) => {
5416                Self::from_f64(value).map_err(|error| NodeError::InvalidRequest(error.to_string()))
5417            }
5418            GraphValueDto::String(value) => Ok(Self::String(value)),
5419            GraphValueDto::Bytes(value) => decode_base64("value", &value).map(Self::Bytes),
5420        }
5421    }
5422}
5423
5424#[cfg(feature = "graph")]
5425impl From<GraphValueV1> for GraphValueDto {
5426    fn from(value: GraphValueV1) -> Self {
5427        match value {
5428            GraphValueV1::Null => Self::Null,
5429            GraphValueV1::Bool(value) => Self::Bool(value),
5430            GraphValueV1::I64(value) => Self::I64(value),
5431            GraphValueV1::U64(value) => Self::U64(value),
5432            GraphValueV1::F64(value) => Self::F64(value.get()),
5433            GraphValueV1::String(value) => Self::String(value),
5434            GraphValueV1::Bytes(value) => Self::Bytes(encode_base64(&value)),
5435        }
5436    }
5437}
5438
5439#[cfg(any(feature = "graph", feature = "kv"))]
5440fn encode_base64(bytes: &[u8]) -> String {
5441    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
5442    let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4);
5443    for chunk in bytes.chunks(3) {
5444        let first = chunk[0];
5445        let second = chunk.get(1).copied().unwrap_or(0);
5446        let third = chunk.get(2).copied().unwrap_or(0);
5447        encoded.push(ALPHABET[usize::from(first >> 2)] as char);
5448        encoded.push(ALPHABET[usize::from(((first & 0x03) << 4) | (second >> 4))] as char);
5449        if chunk.len() > 1 {
5450            encoded.push(ALPHABET[usize::from(((second & 0x0f) << 2) | (third >> 6))] as char);
5451        } else {
5452            encoded.push('=');
5453        }
5454        if chunk.len() > 2 {
5455            encoded.push(ALPHABET[usize::from(third & 0x3f)] as char);
5456        } else {
5457            encoded.push('=');
5458        }
5459    }
5460    encoded
5461}
5462
5463#[cfg(any(feature = "graph", feature = "kv"))]
5464fn decode_base64(field: &str, encoded: &str) -> Result<Vec<u8>, NodeError> {
5465    fn sextet(byte: u8) -> Option<u8> {
5466        match byte {
5467            b'A'..=b'Z' => Some(byte - b'A'),
5468            b'a'..=b'z' => Some(byte - b'a' + 26),
5469            b'0'..=b'9' => Some(byte - b'0' + 52),
5470            b'+' => Some(62),
5471            b'/' => Some(63),
5472            _ => None,
5473        }
5474    }
5475
5476    let bytes = encoded.as_bytes();
5477    if !bytes.len().is_multiple_of(4) {
5478        return Err(NodeError::InvalidRequest(format!(
5479            "{field} must be canonical padded base64"
5480        )));
5481    }
5482    let mut decoded = Vec::with_capacity(bytes.len() / 4 * 3);
5483    for (chunk_index, chunk) in bytes.chunks_exact(4).enumerate() {
5484        let last = chunk_index + 1 == bytes.len() / 4;
5485        let first = sextet(chunk[0]);
5486        let second = sextet(chunk[1]);
5487        let third = (chunk[2] != b'=').then(|| sextet(chunk[2])).flatten();
5488        let fourth = (chunk[3] != b'=').then(|| sextet(chunk[3])).flatten();
5489        let has_padding = chunk[2] == b'=' || chunk[3] == b'=';
5490        if first.is_none()
5491            || second.is_none()
5492            || (chunk[2] != b'=' && third.is_none())
5493            || (chunk[3] != b'=' && fourth.is_none())
5494            || (!last && has_padding)
5495            || (chunk[2] == b'=' && chunk[3] != b'=')
5496        {
5497            return Err(NodeError::InvalidRequest(format!(
5498                "{field} must be canonical padded base64"
5499            )));
5500        }
5501        let first = first.unwrap();
5502        let second = second.unwrap();
5503        decoded.push((first << 2) | (second >> 4));
5504        if let Some(third) = third {
5505            decoded.push((second << 4) | (third >> 2));
5506            if let Some(fourth) = fourth {
5507                decoded.push((third << 6) | fourth);
5508            } else if third & 0x03 != 0 {
5509                return Err(NodeError::InvalidRequest(format!(
5510                    "{field} must be canonical padded base64"
5511                )));
5512            }
5513        } else if second & 0x0f != 0 {
5514            return Err(NodeError::InvalidRequest(format!(
5515                "{field} must be canonical padded base64"
5516            )));
5517        }
5518    }
5519    Ok(decoded)
5520}
5521
5522enum Materializer {
5523    #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5524    Unavailable,
5525    #[cfg(feature = "sql")]
5526    Sql(Box<SqliteStateMachine>),
5527    #[cfg(feature = "graph")]
5528    Graph(Arc<LadybugStateMachine>),
5529    #[cfg(feature = "kv")]
5530    Kv(Arc<RedbStateMachine>),
5531}
5532
5533#[cfg(any(feature = "sql", feature = "kv"))]
5534fn quarantine_materializer(data_dir: &Path, directory: &str) -> Result<(), NodeError> {
5535    static SEQUENCE: AtomicUsize = AtomicUsize::new(0);
5536    let source = data_dir.join(directory);
5537    if !source.exists() {
5538        return Ok(());
5539    }
5540    loop {
5541        let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
5542        let target = data_dir.join(format!(
5543            "{directory}.quarantine-{}-{sequence}",
5544            std::process::id()
5545        ));
5546        match fs::rename(&source, target) {
5547            Ok(()) => return Ok(()),
5548            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
5549            Err(error) => return Err(NodeError::Storage(error.to_string())),
5550        }
5551    }
5552}
5553
5554#[cfg(feature = "sql")]
5555struct SqlMaterializerGuard<'a>(MutexGuard<'a, Materializer>);
5556
5557#[cfg(feature = "sql")]
5558impl std::ops::Deref for SqlMaterializerGuard<'_> {
5559    type Target = SqliteStateMachine;
5560
5561    fn deref(&self) -> &Self::Target {
5562        match &*self.0 {
5563            Materializer::Sql(state) => state,
5564            #[cfg(feature = "graph")]
5565            Materializer::Graph(_) => unreachable!("SQL guard validated the materializer profile"),
5566            #[cfg(feature = "kv")]
5567            Materializer::Kv(_) => unreachable!("SQL guard validated the materializer profile"),
5568        }
5569    }
5570}
5571
5572impl Materializer {
5573    fn close_for_handoff(self) -> Result<(), String> {
5574        match self {
5575            #[cfg(feature = "sql")]
5576            Self::Sql(state) => state.close_for_handoff().map_err(|error| error.to_string()),
5577            #[cfg(feature = "graph")]
5578            Self::Graph(_) => Ok(()),
5579            #[cfg(feature = "kv")]
5580            Self::Kv(_) => Ok(()),
5581            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5582            Self::Unavailable => Ok(()),
5583        }
5584    }
5585
5586    fn ensure_profile_available(profile: ExecutionProfile) -> Result<(), NodeError> {
5587        if execution_profile_compiled(profile) {
5588            Ok(())
5589        } else {
5590            Err(NodeError::Unavailable(format!(
5591                "{} execution profile is not compiled in",
5592                profile.as_str()
5593            )))
5594        }
5595    }
5596
5597    #[cfg_attr(not(feature = "sql"), allow(unused_variables))]
5598    fn open(
5599        config: &NodeConfig,
5600        configuration_state: &ConfigurationState,
5601        recovery_anchor: Option<&RecoveryAnchor>,
5602    ) -> Result<Self, NodeError> {
5603        match config.execution_profile() {
5604            ExecutionProfile::Sqlite => {
5605                #[cfg(feature = "sql")]
5606                {
5607                    let path = config.data_dir().join("sqlite/db.sqlite");
5608                    let open = || {
5609                        rhiza_sql::cleanup_empty_wal_sidecars_after_owner_fence(&path)?;
5610                        SqliteStateMachine::open_with_configuration(
5611                            &path,
5612                            config.cluster_id(),
5613                            config.node_id(),
5614                            config.epoch(),
5615                            configuration_state.clone(),
5616                        )
5617                    };
5618                    let state = match open() {
5619                        Ok(state) => state,
5620                        Err(open_error) => match recovery_anchor {
5621                            Some(anchor) => {
5622                                eprintln!(
5623                                    "SQL materializer strict reopen failed before snapshot recovery: {open_error}"
5624                                );
5625                                return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())));
5626                            }
5627                            None => {
5628                                quarantine_materializer(config.data_dir(), "sqlite")?;
5629                                open().map_err(|error| NodeError::Storage(error.to_string()))?
5630                            }
5631                        },
5632                    };
5633                    Ok(Self::Sql(Box::new(state)))
5634                }
5635                #[cfg(not(feature = "sql"))]
5636                Err(NodeError::Unavailable(
5637                    "sql execution profile is not compiled in".into(),
5638                ))
5639            }
5640            ExecutionProfile::Graph => {
5641                #[cfg(feature = "graph")]
5642                {
5643                    LadybugStateMachine::open(
5644                        config.data_dir().join("ladybug/graph.lbug"),
5645                        config.cluster_id(),
5646                        config.node_id(),
5647                        config.epoch(),
5648                        configuration_state.config_id(),
5649                    )
5650                    .map(Arc::new)
5651                    .map(Self::Graph)
5652                    .map_err(|error| NodeError::Storage(error.to_string()))
5653                }
5654                #[cfg(not(feature = "graph"))]
5655                Err(NodeError::Unavailable(
5656                    "graph execution profile is not compiled in".into(),
5657                ))
5658            }
5659            ExecutionProfile::Kv => {
5660                #[cfg(feature = "kv")]
5661                {
5662                    let open = || {
5663                        RedbStateMachine::open(
5664                            config.data_dir().join("kv/data.redb"),
5665                            config.cluster_id(),
5666                            config.node_id(),
5667                            config.epoch(),
5668                            configuration_state.config_id(),
5669                        )
5670                    };
5671                    let state = match open() {
5672                        Ok(state) => state,
5673                        Err(_) => match recovery_anchor {
5674                            Some(anchor) => {
5675                                return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())))
5676                            }
5677                            _ => {
5678                                quarantine_materializer(config.data_dir(), "kv")?;
5679                                open().map_err(|error| NodeError::Storage(error.to_string()))?
5680                            }
5681                        },
5682                    };
5683                    Ok(Self::Kv(Arc::new(state)))
5684                }
5685                #[cfg(not(feature = "kv"))]
5686                Err(NodeError::Unavailable(
5687                    "kv execution profile is not compiled in".into(),
5688                ))
5689            }
5690        }
5691    }
5692
5693    #[cfg_attr(not(feature = "sql"), allow(unused_variables))]
5694    fn open_detached(
5695        data_dir: &Path,
5696        profile: ExecutionProfile,
5697        cluster_id: &str,
5698        node_id: &str,
5699        epoch: u64,
5700        configuration_state: &ConfigurationState,
5701    ) -> Result<Self, NodeError> {
5702        Self::ensure_profile_available(profile)?;
5703        match profile {
5704            ExecutionProfile::Sqlite => {
5705                #[cfg(feature = "sql")]
5706                {
5707                    SqliteStateMachine::open_with_configuration(
5708                        data_dir.join("sqlite/db.sqlite"),
5709                        cluster_id,
5710                        node_id,
5711                        epoch,
5712                        configuration_state.clone(),
5713                    )
5714                    .map(Box::new)
5715                    .map(Self::Sql)
5716                    .map_err(|error| NodeError::Storage(error.to_string()))
5717                }
5718                #[cfg(not(feature = "sql"))]
5719                Err(NodeError::Unavailable(
5720                    "sql execution profile is not compiled in".into(),
5721                ))
5722            }
5723            ExecutionProfile::Graph => {
5724                #[cfg(feature = "graph")]
5725                {
5726                    LadybugStateMachine::open(
5727                        data_dir.join("ladybug/graph.lbug"),
5728                        cluster_id,
5729                        node_id,
5730                        epoch,
5731                        configuration_state.config_id(),
5732                    )
5733                    .map(Arc::new)
5734                    .map(Self::Graph)
5735                    .map_err(|error| NodeError::Storage(error.to_string()))
5736                }
5737                #[cfg(not(feature = "graph"))]
5738                Err(NodeError::Unavailable(
5739                    "graph execution profile is not compiled in".into(),
5740                ))
5741            }
5742            ExecutionProfile::Kv => {
5743                #[cfg(feature = "kv")]
5744                {
5745                    RedbStateMachine::open(
5746                        data_dir.join("kv/data.redb"),
5747                        cluster_id,
5748                        node_id,
5749                        epoch,
5750                        configuration_state.config_id(),
5751                    )
5752                    .map(Arc::new)
5753                    .map(Self::Kv)
5754                    .map_err(|error| NodeError::Storage(error.to_string()))
5755                }
5756                #[cfg(not(feature = "kv"))]
5757                Err(NodeError::Unavailable(
5758                    "kv execution profile is not compiled in".into(),
5759                ))
5760            }
5761        }
5762    }
5763
5764    fn profile(&self) -> ExecutionProfile {
5765        match self {
5766            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5767            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5768            #[cfg(feature = "sql")]
5769            Self::Sql(_) => ExecutionProfile::Sqlite,
5770            #[cfg(feature = "graph")]
5771            Self::Graph(_) => ExecutionProfile::Graph,
5772            #[cfg(feature = "kv")]
5773            Self::Kv(_) => ExecutionProfile::Kv,
5774        }
5775    }
5776
5777    fn applied_index(&self) -> Result<LogIndex, String> {
5778        match self {
5779            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5780            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5781            #[cfg(feature = "sql")]
5782            Self::Sql(state) => state
5783                .applied_index_value()
5784                .map_err(|error| error.to_string()),
5785            #[cfg(feature = "graph")]
5786            Self::Graph(state) => state.applied_index().map_err(|error| error.to_string()),
5787            #[cfg(feature = "kv")]
5788            Self::Kv(state) => state.applied_index().map_err(|error| error.to_string()),
5789        }
5790    }
5791
5792    fn applied_hash(&self) -> Result<LogHash, String> {
5793        match self {
5794            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5795            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5796            #[cfg(feature = "sql")]
5797            Self::Sql(state) => state
5798                .applied_hash_value()
5799                .map_err(|error| error.to_string()),
5800            #[cfg(feature = "graph")]
5801            Self::Graph(state) => state.applied_hash().map_err(|error| error.to_string()),
5802            #[cfg(feature = "kv")]
5803            Self::Kv(state) => state.applied_hash().map_err(|error| error.to_string()),
5804        }
5805    }
5806
5807    fn applied_tip(&self) -> Result<LogAnchor, String> {
5808        match self {
5809            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5810            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5811            #[cfg(feature = "sql")]
5812            Self::Sql(state) => state
5813                .applied_tip()
5814                .map(|tip| LogAnchor::new(tip.applied_index(), tip.applied_hash()))
5815                .map_err(|error| error.to_string()),
5816            #[cfg(feature = "graph")]
5817            Self::Graph(state) => state.applied_tip().map_err(|error| error.to_string()),
5818            #[cfg(feature = "kv")]
5819            Self::Kv(state) => state.applied_tip().map_err(|error| error.to_string()),
5820        }
5821    }
5822
5823    fn configuration_state(&self) -> Result<Option<ConfigurationState>, String> {
5824        match self {
5825            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5826            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5827            #[cfg(feature = "sql")]
5828            Self::Sql(state) => state
5829                .configuration_state_value()
5830                .map(Some)
5831                .map_err(|error| error.to_string()),
5832            #[cfg(feature = "graph")]
5833            Self::Graph(_) => Ok(None),
5834            #[cfg(feature = "kv")]
5835            Self::Kv(_) => Ok(None),
5836        }
5837    }
5838
5839    fn apply_entry(&self, entry: &LogEntry) -> Result<Option<SqlCommandResult>, String> {
5840        match self {
5841            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5842            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5843            #[cfg(feature = "sql")]
5844            Self::Sql(state) => state
5845                .apply_entry_with_result(entry)
5846                .map(|outcome| outcome.sql_result().cloned())
5847                .map_err(|error| error.to_string()),
5848            #[cfg(feature = "graph")]
5849            Self::Graph(state) => state
5850                .apply_entry(entry)
5851                .map(|_| None)
5852                .map_err(|error| error.to_string()),
5853            #[cfg(feature = "kv")]
5854            Self::Kv(state) => state
5855                .apply_entry(entry)
5856                .map(|_| None)
5857                .map_err(|error| error.to_string()),
5858        }
5859    }
5860}
5861
5862const READ_BARRIER_COALESCE_WINDOW: Duration = Duration::from_micros(50);
5863
5864struct ReadBarrierRounds {
5865    state: Mutex<ReadBarrierRoundsState>,
5866    collection_window: Duration,
5867}
5868
5869struct ReadBarrierRoundsState {
5870    tail: Option<Arc<ReadBarrierRound>>,
5871    next_generation: u64,
5872}
5873
5874struct ReadBarrierRound {
5875    #[cfg(test)]
5876    generation: u64,
5877    collection_deadline: Instant,
5878    predecessor: Mutex<Option<Arc<ReadBarrierRound>>>,
5879    phase: Mutex<ReadBarrierRoundPhase>,
5880    changed: Condvar,
5881}
5882
5883#[derive(Clone)]
5884enum ReadBarrierRoundPhase {
5885    Collecting,
5886    Running,
5887    Complete(Result<LogAnchor, NodeError>),
5888}
5889
5890struct ReadBarrierParticipant {
5891    round: Arc<ReadBarrierRound>,
5892    leader: bool,
5893}
5894
5895struct ReadBarrierPublication {
5896    round: Arc<ReadBarrierRound>,
5897    published: bool,
5898}
5899
5900impl ReadBarrierRounds {
5901    fn new(collection_window: Duration) -> Self {
5902        Self {
5903            state: Mutex::new(ReadBarrierRoundsState {
5904                tail: None,
5905                next_generation: 0,
5906            }),
5907            collection_window,
5908        }
5909    }
5910
5911    fn join(&self) -> Result<ReadBarrierParticipant, NodeError> {
5912        let mut state = self
5913            .state
5914            .lock()
5915            .map_err(|_| NodeError::Invariant("read barrier generation lock is poisoned".into()))?;
5916        if let Some(tail) = state.tail.as_ref() {
5917            let collecting = matches!(
5918                *tail.phase.lock().map_err(|_| {
5919                    NodeError::Invariant("read barrier round lock is poisoned".into())
5920                })?,
5921                ReadBarrierRoundPhase::Collecting
5922            );
5923            if collecting {
5924                return Ok(ReadBarrierParticipant {
5925                    round: Arc::clone(tail),
5926                    leader: false,
5927                });
5928            }
5929        }
5930
5931        let generation = state
5932            .next_generation
5933            .checked_add(1)
5934            .ok_or_else(|| NodeError::Invariant("read barrier generation is exhausted".into()))?;
5935        state.next_generation = generation;
5936        let collection_deadline = Instant::now()
5937            .checked_add(self.collection_window)
5938            .unwrap_or_else(Instant::now);
5939        let round = Arc::new(ReadBarrierRound {
5940            #[cfg(test)]
5941            generation,
5942            collection_deadline,
5943            predecessor: Mutex::new(state.tail.clone()),
5944            phase: Mutex::new(ReadBarrierRoundPhase::Collecting),
5945            changed: Condvar::new(),
5946        });
5947        state.tail = Some(Arc::clone(&round));
5948        Ok(ReadBarrierParticipant {
5949            round,
5950            leader: true,
5951        })
5952    }
5953
5954    fn cancel_waiters(&self) {
5955        let mut round = self.state.lock().ok().and_then(|state| state.tail.clone());
5956        while let Some(current) = round {
5957            if let Ok(_phase) = current.phase.lock() {
5958                current.changed.notify_all();
5959            }
5960            round = current
5961                .predecessor
5962                .lock()
5963                .ok()
5964                .and_then(|predecessor| predecessor.clone());
5965        }
5966    }
5967}
5968
5969impl ReadBarrierRound {
5970    fn wait_complete(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
5971        let mut phase = self
5972            .phase
5973            .lock()
5974            .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5975        loop {
5976            if matches!(*phase, ReadBarrierRoundPhase::Complete(_)) {
5977                return Ok(());
5978            }
5979            if cancelled.load(Ordering::Acquire) {
5980                return Err(NodeError::Unavailable(
5981                    "read barrier cancelled during shutdown".into(),
5982                ));
5983            }
5984            phase = self
5985                .changed
5986                .wait(phase)
5987                .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5988        }
5989    }
5990
5991    fn result(&self, cancelled: &AtomicBool) -> Result<LogAnchor, NodeError> {
5992        let mut phase = self
5993            .phase
5994            .lock()
5995            .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5996        loop {
5997            if let ReadBarrierRoundPhase::Complete(result) = &*phase {
5998                return result.clone();
5999            }
6000            if cancelled.load(Ordering::Acquire) {
6001                return Err(NodeError::Unavailable(
6002                    "read barrier cancelled during shutdown".into(),
6003                ));
6004            }
6005            phase = self
6006                .changed
6007                .wait(phase)
6008                .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
6009        }
6010    }
6011
6012    fn complete(&self, result: Result<LogAnchor, NodeError>) {
6013        if let Ok(mut phase) = self.phase.lock() {
6014            if !matches!(*phase, ReadBarrierRoundPhase::Complete(_)) {
6015                *phase = ReadBarrierRoundPhase::Complete(result);
6016            }
6017            self.changed.notify_all();
6018        } else {
6019            self.changed.notify_all();
6020        }
6021    }
6022}
6023
6024impl ReadBarrierParticipant {
6025    #[cfg(test)]
6026    fn generation(&self) -> u64 {
6027        self.round.generation
6028    }
6029
6030    #[cfg(test)]
6031    fn is_leader(&self) -> bool {
6032        self.leader
6033    }
6034
6035    fn publication(&self) -> Option<ReadBarrierPublication> {
6036        self.leader.then(|| ReadBarrierPublication {
6037            round: Arc::clone(&self.round),
6038            published: false,
6039        })
6040    }
6041
6042    fn wait(&self, cancelled: &AtomicBool) -> Result<LogAnchor, NodeError> {
6043        self.round.result(cancelled)
6044    }
6045}
6046
6047impl ReadBarrierPublication {
6048    fn wait_turn(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
6049        let predecessor = self
6050            .round
6051            .predecessor
6052            .lock()
6053            .map_err(|_| NodeError::Invariant("read barrier predecessor lock is poisoned".into()))?
6054            .clone();
6055        if let Some(predecessor) = predecessor {
6056            predecessor.wait_complete(cancelled)?;
6057            self.round
6058                .predecessor
6059                .lock()
6060                .map_err(|_| {
6061                    NodeError::Invariant("read barrier predecessor lock is poisoned".into())
6062                })?
6063                .take();
6064        }
6065
6066        let mut phase = self
6067            .round
6068            .phase
6069            .lock()
6070            .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
6071        loop {
6072            if cancelled.load(Ordering::Acquire) {
6073                return Err(NodeError::Unavailable(
6074                    "read barrier cancelled during shutdown".into(),
6075                ));
6076            }
6077            if !matches!(*phase, ReadBarrierRoundPhase::Collecting) {
6078                return Err(NodeError::Invariant(
6079                    "read barrier leader left the collecting phase early".into(),
6080                ));
6081            }
6082            let now = Instant::now();
6083            if now >= self.round.collection_deadline {
6084                return Ok(());
6085            }
6086            let wait = self
6087                .round
6088                .collection_deadline
6089                .saturating_duration_since(now);
6090            let (next, _) =
6091                self.round.changed.wait_timeout(phase, wait).map_err(|_| {
6092                    NodeError::Invariant("read barrier round lock is poisoned".into())
6093                })?;
6094            phase = next;
6095        }
6096    }
6097
6098    fn start(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
6099        let mut phase = self
6100            .round
6101            .phase
6102            .lock()
6103            .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
6104        if cancelled.load(Ordering::Acquire) {
6105            return Err(NodeError::Unavailable(
6106                "read barrier cancelled during shutdown".into(),
6107            ));
6108        }
6109        if !matches!(*phase, ReadBarrierRoundPhase::Collecting) {
6110            return Err(NodeError::Invariant(
6111                "read barrier generation cannot start twice".into(),
6112            ));
6113        }
6114        *phase = ReadBarrierRoundPhase::Running;
6115        Ok(())
6116    }
6117
6118    fn publish(&mut self, result: Result<LogAnchor, NodeError>) {
6119        self.round.complete(result);
6120        self.published = true;
6121    }
6122}
6123
6124impl Drop for ReadBarrierPublication {
6125    fn drop(&mut self) {
6126        if !self.published {
6127            self.round.complete(Err(NodeError::Unavailable(
6128                "read barrier generation leader terminated".into(),
6129            )));
6130        }
6131    }
6132}
6133
6134#[cfg(all(test, feature = "kv"))]
6135type KvGroupCommitAfterExecuteHook = Arc<dyn Fn(&NodeRuntime) + Send + Sync>;
6136
6137pub struct NodeRuntime {
6138    config: NodeConfig,
6139    consensus: Arc<ThreeNodeConsensus>,
6140    log_store: FileLogStore,
6141    materializer: Mutex<Materializer>,
6142    commit: Mutex<()>,
6143    #[cfg(feature = "sql")]
6144    sql_group_commit: SqlGroupCommitQueue,
6145    #[cfg(feature = "kv")]
6146    kv_group_commit: KvGroupCommitQueue,
6147    read_barriers: ReadBarrierRounds,
6148    checkpointing: AtomicBool,
6149    operation_cancelled: AtomicBool,
6150    operation_cancelled_notify: tokio::sync::Notify,
6151    ready: AtomicBool,
6152    fatal: AtomicBool,
6153    fatal_reason: Mutex<Option<String>>,
6154    #[cfg(test)]
6155    materialized_tip_checks: AtomicUsize,
6156    #[cfg(all(test, feature = "sql"))]
6157    sql_group_commit_before_execute_hook: Option<Arc<dyn Fn() + Send + Sync>>,
6158    #[cfg(all(test, feature = "kv"))]
6159    kv_group_commit_before_execute_hook: Option<Arc<dyn Fn() + Send + Sync>>,
6160    #[cfg(all(test, feature = "kv"))]
6161    kv_group_commit_after_execute_hook: Option<KvGroupCommitAfterExecuteHook>,
6162    _data_root_lock: fs::File,
6163}
6164
6165#[cfg(feature = "sql")]
6166struct ExecutedPayload {
6167    response: WriteResponse,
6168    sql_result: Option<SqlCommandResult>,
6169}
6170
6171#[cfg(feature = "sql")]
6172trait SqlWritePhaseProfile {
6173    type Mark;
6174
6175    fn mark(&self) -> Self::Mark;
6176    fn commit_lock_acquired(&mut self);
6177    fn add_precheck_classification(&mut self, mark: Self::Mark);
6178    fn add_qwal_prepare(&mut self, mark: Self::Mark);
6179    fn add_consensus_propose(&mut self, mark: Self::Mark);
6180    fn add_local_qlog_mirror_append(&mut self, mark: Self::Mark);
6181    fn add_sql_materializer_apply(&mut self, mark: Self::Mark);
6182    fn record_success(&mut self, batch_member_count: usize);
6183}
6184
6185#[cfg(feature = "sql")]
6186struct DisabledSqlWritePhaseProfile;
6187
6188#[cfg(feature = "sql")]
6189impl SqlWritePhaseProfile for DisabledSqlWritePhaseProfile {
6190    type Mark = ();
6191
6192    #[inline]
6193    fn mark(&self) {}
6194
6195    #[inline]
6196    fn commit_lock_acquired(&mut self) {}
6197
6198    #[inline]
6199    fn add_precheck_classification(&mut self, (): ()) {}
6200
6201    #[inline]
6202    fn add_qwal_prepare(&mut self, (): ()) {}
6203
6204    #[inline]
6205    fn add_consensus_propose(&mut self, (): ()) {}
6206
6207    #[inline]
6208    fn add_local_qlog_mirror_append(&mut self, (): ()) {}
6209
6210    #[inline]
6211    fn add_sql_materializer_apply(&mut self, (): ()) {}
6212
6213    #[inline]
6214    fn record_success(&mut self, _batch_member_count: usize) {}
6215}
6216
6217#[cfg(feature = "sql")]
6218struct EnabledSqlWritePhaseProfile {
6219    observer: SqlWriteProfiler,
6220    service_started: Instant,
6221    commit_lock_wait: Duration,
6222    precheck_classification: Duration,
6223    qwal_prepare: Duration,
6224    consensus_propose: Duration,
6225    local_qlog_mirror_append: Duration,
6226    sql_materializer_apply: Duration,
6227}
6228
6229#[cfg(feature = "sql")]
6230impl EnabledSqlWritePhaseProfile {
6231    fn new(observer: SqlWriteProfiler) -> Self {
6232        Self {
6233            observer,
6234            service_started: Instant::now(),
6235            commit_lock_wait: Duration::ZERO,
6236            precheck_classification: Duration::ZERO,
6237            qwal_prepare: Duration::ZERO,
6238            consensus_propose: Duration::ZERO,
6239            local_qlog_mirror_append: Duration::ZERO,
6240            sql_materializer_apply: Duration::ZERO,
6241        }
6242    }
6243
6244    fn reset(&mut self) {
6245        self.service_started = Instant::now();
6246        self.commit_lock_wait = Duration::ZERO;
6247        self.precheck_classification = Duration::ZERO;
6248        self.qwal_prepare = Duration::ZERO;
6249        self.consensus_propose = Duration::ZERO;
6250        self.local_qlog_mirror_append = Duration::ZERO;
6251        self.sql_materializer_apply = Duration::ZERO;
6252    }
6253}
6254
6255#[cfg(feature = "sql")]
6256impl SqlWritePhaseProfile for EnabledSqlWritePhaseProfile {
6257    type Mark = Instant;
6258
6259    #[inline]
6260    fn mark(&self) -> Instant {
6261        Instant::now()
6262    }
6263
6264    fn commit_lock_acquired(&mut self) {
6265        self.commit_lock_wait = self.service_started.elapsed();
6266    }
6267
6268    fn add_precheck_classification(&mut self, mark: Instant) {
6269        self.precheck_classification += mark.elapsed();
6270    }
6271
6272    fn add_qwal_prepare(&mut self, mark: Instant) {
6273        self.qwal_prepare += mark.elapsed();
6274    }
6275
6276    fn add_consensus_propose(&mut self, mark: Instant) {
6277        self.consensus_propose += mark.elapsed();
6278    }
6279
6280    fn add_local_qlog_mirror_append(&mut self, mark: Instant) {
6281        self.local_qlog_mirror_append += mark.elapsed();
6282    }
6283
6284    fn add_sql_materializer_apply(&mut self, mark: Instant) {
6285        self.sql_materializer_apply += mark.elapsed();
6286    }
6287
6288    fn record_success(&mut self, batch_member_count: usize) {
6289        let total_service_us = duration_as_u64_micros(self.service_started.elapsed());
6290        let commit_lock_wait_us = duration_as_u64_micros(self.commit_lock_wait);
6291        let precheck_classification_us = duration_as_u64_micros(self.precheck_classification);
6292        let qwal_prepare_us = duration_as_u64_micros(self.qwal_prepare);
6293        let consensus_propose_us = duration_as_u64_micros(self.consensus_propose);
6294        let local_qlog_mirror_append_us = duration_as_u64_micros(self.local_qlog_mirror_append);
6295        let sql_materializer_apply_us = duration_as_u64_micros(self.sql_materializer_apply);
6296        let named_us = commit_lock_wait_us
6297            .saturating_add(precheck_classification_us)
6298            .saturating_add(qwal_prepare_us)
6299            .saturating_add(consensus_propose_us)
6300            .saturating_add(local_qlog_mirror_append_us)
6301            .saturating_add(sql_materializer_apply_us);
6302        self.observer.record(SqlWriteProfileSample {
6303            batch_member_count,
6304            commit_lock_wait_us,
6305            precheck_classification_us,
6306            qwal_prepare_us,
6307            consensus_propose_us,
6308            local_qlog_mirror_append_us,
6309            sql_materializer_apply_us,
6310            response_other_total_us: total_service_us.saturating_sub(named_us),
6311            total_service_us,
6312        });
6313        self.reset();
6314    }
6315}
6316
6317#[cfg(feature = "sql")]
6318fn duration_as_u64_micros(duration: Duration) -> u64 {
6319    u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
6320}
6321
6322#[cfg(feature = "sql")]
6323type CheckedSqlRequest = Result<Option<(RequestOutcome, SqlCommandResult)>, NodeError>;
6324
6325#[cfg(feature = "sql")]
6326#[derive(Clone, Debug, Eq, PartialEq)]
6327pub struct VerifiedSnapshotPublication {
6328    anchor: RecoveryAnchor,
6329}
6330
6331impl fmt::Debug for NodeRuntime {
6332    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6333        f.debug_struct("NodeRuntime")
6334            .field("config", &self.config)
6335            .field("ready", &self.ready.load(Ordering::Acquire))
6336            .field("fatal", &self.fatal.load(Ordering::Acquire))
6337            .finish_non_exhaustive()
6338    }
6339}
6340
6341impl NodeRuntime {
6342    pub fn open(
6343        config: NodeConfig,
6344        consensus: Arc<ThreeNodeConsensus>,
6345        peer_candidates: &[&dyn LogPeer],
6346    ) -> Result<Self, NodeError> {
6347        Self::open_cancellable(config, consensus, peer_candidates, &StartupIoContext::new())
6348    }
6349
6350    pub fn open_cancellable(
6351        config: NodeConfig,
6352        consensus: Arc<ThreeNodeConsensus>,
6353        peer_candidates: &[&dyn LogPeer],
6354        startup: &StartupIoContext,
6355    ) -> Result<Self, NodeError> {
6356        startup.check("runtime configuration validation")?;
6357        if config.ack_mode == AckMode::DrStrong {
6358            return Err(NodeError::UnsupportedAckMode(AckMode::DrStrong));
6359        }
6360        Materializer::ensure_profile_available(config.execution_profile())?;
6361        startup.check("runtime data directory creation")?;
6362        fs::create_dir_all(&config.data_dir)
6363            .map_err(|error| NodeError::Storage(error.to_string()))?;
6364        startup.check("runtime data lock acquisition")?;
6365        let lock_path = config.data_dir.join(".node.lock");
6366        let data_root_lock = fs::OpenOptions::new()
6367            .read(true)
6368            .write(true)
6369            .create(true)
6370            .truncate(false)
6371            .open(&lock_path)
6372            .map_err(|error| NodeError::Storage(error.to_string()))?;
6373        match data_root_lock.try_lock() {
6374            Ok(()) => {}
6375            Err(fs::TryLockError::WouldBlock) => {
6376                return Err(NodeError::DataRootLocked(config.data_dir.clone()));
6377            }
6378            Err(fs::TryLockError::Error(error)) => {
6379                return Err(NodeError::Storage(error.to_string()));
6380            }
6381        }
6382
6383        startup.check("qlog open and recovery")?;
6384        let log_store = FileLogStore::open_with_configuration(
6385            config.data_dir.join("consensus/log"),
6386            &config.cluster_id,
6387            config.epoch,
6388            config.log_initial_configuration.clone(),
6389        )
6390        .map_err(|error| NodeError::Storage(error.to_string()))?;
6391        let persisted_configuration = log_store
6392            .configuration_state()
6393            .map_err(|error| NodeError::Storage(error.to_string()))?;
6394        let recovery_anchor = log_store
6395            .logical_state()
6396            .map_err(|error| NodeError::Storage(error.to_string()))?
6397            .anchor;
6398        startup.check("materializer open and reconciliation")?;
6399        let materializer =
6400            Materializer::open(&config, &persisted_configuration, recovery_anchor.as_ref())?;
6401        reconcile_local_storage(&config, &log_store, &materializer)?;
6402        startup.check("peer recovery")?;
6403        recover_peer_candidates(
6404            &config,
6405            consensus.as_ref(),
6406            &log_store,
6407            &materializer,
6408            peer_candidates,
6409            startup,
6410        )?;
6411        recover_startup_decisions(
6412            &config,
6413            consensus.as_ref(),
6414            &log_store,
6415            &materializer,
6416            startup,
6417        )?;
6418        startup.check("runtime readiness publication")?;
6419
6420        Ok(Self {
6421            #[cfg(feature = "sql")]
6422            sql_group_commit: SqlGroupCommitQueue::new(config.sql_group_commit_queue_capacity),
6423            #[cfg(feature = "kv")]
6424            kv_group_commit: KvGroupCommitQueue::new(),
6425            config,
6426            consensus,
6427            log_store,
6428            materializer: Mutex::new(materializer),
6429            commit: Mutex::new(()),
6430            read_barriers: ReadBarrierRounds::new(READ_BARRIER_COALESCE_WINDOW),
6431            checkpointing: AtomicBool::new(false),
6432            operation_cancelled: AtomicBool::new(false),
6433            operation_cancelled_notify: tokio::sync::Notify::new(),
6434            ready: AtomicBool::new(true),
6435            fatal: AtomicBool::new(false),
6436            fatal_reason: Mutex::new(None),
6437            #[cfg(test)]
6438            materialized_tip_checks: AtomicUsize::new(0),
6439            #[cfg(all(test, feature = "sql"))]
6440            sql_group_commit_before_execute_hook: None,
6441            #[cfg(all(test, feature = "kv"))]
6442            kv_group_commit_before_execute_hook: None,
6443            #[cfg(all(test, feature = "kv"))]
6444            kv_group_commit_after_execute_hook: None,
6445            _data_root_lock: data_root_lock,
6446        })
6447    }
6448
6449    #[cfg(feature = "sql")]
6450    pub fn write(
6451        &self,
6452        request_id: &str,
6453        key: &str,
6454        value: &str,
6455    ) -> Result<WriteResponse, NodeError> {
6456        let payload = canonical_put(request_id, key, value)?;
6457        let _commit = self.lock_commit()?;
6458        self.execute_put_payload_locked(request_id, key, value, payload)
6459            .map(|outcome| outcome.response)
6460    }
6461
6462    #[cfg(feature = "graph")]
6463    pub fn mutate_graph(&self, command: GraphCommandV1) -> Result<GraphMutationOutcome, NodeError> {
6464        let payload = encode_replicated_graph_command(&command)
6465            .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6466        if payload.len() > MAX_COMMAND_BYTES {
6467            return Err(NodeError::InvalidRequest(format!(
6468                "command exceeds {MAX_COMMAND_BYTES} bytes"
6469            )));
6470        }
6471        let _commit = self.lock_commit()?;
6472        self.mutate_graph_payload_locked(&command, payload)
6473    }
6474
6475    /// Executes an ordered, non-atomic batch of graph mutations.
6476    ///
6477    /// Valid commands may share one QuePaxa log entry. Per-command conflicts remain isolated in
6478    /// the returned vector, whose order and length match `commands`. The whole vector is validated
6479    /// before the first write attempt, so an outer `Err` guarantees that nothing was attempted.
6480    #[cfg(feature = "graph")]
6481    #[cfg_attr(
6482        all(not(feature = "sql"), not(feature = "kv")),
6483        allow(unreachable_patterns)
6484    )]
6485    pub fn mutate_graph_batch(
6486        &self,
6487        commands: Vec<GraphCommandV1>,
6488    ) -> Result<Vec<Result<GraphMutationOutcome, NodeError>>, NodeError> {
6489        self.require_execution_profile(ExecutionProfile::Graph)?;
6490        validate_typed_batch_len(commands.len())?;
6491        let mut members = Vec::with_capacity(commands.len());
6492        for command in commands {
6493            let payload = encode_replicated_graph_command(&command)
6494                .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6495            validate_command_size(&payload)?;
6496            members.push(RuntimeBatchMember {
6497                #[cfg(feature = "sql")]
6498                request_id: command.request_id().to_owned(),
6499                payload,
6500                operation: QueuedOperation::Graph(command),
6501            });
6502        }
6503        Ok(self
6504            .execute_client_batch(members)
6505            .into_iter()
6506            .map(|result| {
6507                result.and_then(|response| match response {
6508                    ClientWriteResponse::Graph(outcome) => Ok(outcome),
6509                    _ => Err(NodeError::Invariant(
6510                        "graph batch returned a response for another profile".into(),
6511                    )),
6512                })
6513            })
6514            .collect())
6515    }
6516
6517    #[cfg(feature = "graph")]
6518    fn mutate_graph_payload_locked(
6519        &self,
6520        command: &GraphCommandV1,
6521        payload: Vec<u8>,
6522    ) -> Result<GraphMutationOutcome, NodeError> {
6523        self.ensure_ready()?;
6524        self.ensure_writes_active()?;
6525        if let Some(record) = self.check_graph_request(command.request_id(), &payload)? {
6526            return Ok(GraphMutationOutcome::from_record(record));
6527        }
6528        loop {
6529            let (last_index, last_hash) = self.ensure_materialized_tip()?;
6530            let slot = last_index.checked_add(1).ok_or_else(|| {
6531                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
6532            })?;
6533            let entry = self
6534                .consensus
6535                .propose_at_cancellable(
6536                    slot,
6537                    last_hash,
6538                    Command::new(CommandKind::Deterministic, payload.clone()),
6539                    &self.operation_cancelled,
6540                )
6541                .map_err(|error| self.map_consensus_error(error))?;
6542            self.persist_entry(&entry, slot, last_hash)?;
6543            if let Some(record) = self.check_graph_request(command.request_id(), &payload)? {
6544                return Ok(GraphMutationOutcome::from_record(record));
6545            }
6546            if entry.entry_type == EntryType::Command && entry.payload == payload {
6547                return Err(self.latch(NodeError::Invariant(
6548                    "committed graph request was not recorded by Ladybug".into(),
6549                )));
6550            }
6551        }
6552    }
6553
6554    #[cfg(feature = "graph")]
6555    pub fn get_graph_document(
6556        &self,
6557        id: &str,
6558        consistency: ReadConsistency,
6559    ) -> Result<GraphReadResponse, NodeError> {
6560        match consistency {
6561            ReadConsistency::Local => self.get_graph_document_local(id, None),
6562            ReadConsistency::AppliedIndex(required) => {
6563                self.get_graph_document_local(id, Some(required))
6564            }
6565            ReadConsistency::ReadBarrier => {
6566                let anchor = self.establish_read_barrier()?;
6567                self.validate_read_barrier_before_snapshot(anchor)?;
6568                let response = self.get_graph_document_local(id, Some(anchor.index()))?;
6569                self.validate_read_barrier_snapshot(
6570                    anchor,
6571                    LogAnchor::new(response.applied_index, response.hash),
6572                )?;
6573                Ok(response)
6574            }
6575        }
6576    }
6577
6578    #[cfg(feature = "graph")]
6579    pub fn query_graph(
6580        &self,
6581        statement: &str,
6582        parameters: &BTreeMap<String, GraphParameterValue>,
6583        consistency: ReadConsistency,
6584        max_rows: u32,
6585    ) -> Result<GraphQueryResult, NodeError> {
6586        if max_rows == 0 || max_rows > MAX_GRAPH_MAX_ROWS {
6587            return Err(NodeError::InvalidRequest(format!(
6588                "max_rows must be between 1 and {MAX_GRAPH_MAX_ROWS}"
6589            )));
6590        }
6591        match consistency {
6592            ReadConsistency::Local => self.query_graph_local(statement, parameters, None, max_rows),
6593            ReadConsistency::AppliedIndex(required) => {
6594                self.query_graph_local(statement, parameters, Some(required), max_rows)
6595            }
6596            ReadConsistency::ReadBarrier => {
6597                let anchor = self.establish_read_barrier()?;
6598                self.validate_read_barrier_before_snapshot(anchor)?;
6599                let result =
6600                    self.query_graph_local(statement, parameters, Some(anchor.index()), max_rows)?;
6601                self.validate_read_barrier_snapshot(
6602                    anchor,
6603                    LogAnchor::new(result.applied_index, result.hash),
6604                )?;
6605                Ok(result)
6606            }
6607        }
6608    }
6609
6610    #[cfg(feature = "kv")]
6611    pub fn mutate_kv(&self, command: KvCommandV1) -> Result<KvMutationOutcome, NodeError> {
6612        self.mutate_kv_batch(vec![command])?
6613            .into_iter()
6614            .next()
6615            .expect("one-member KV batch returns one result")
6616    }
6617
6618    /// Executes an ordered, non-atomic batch of KV mutations.
6619    ///
6620    /// Valid commands may share one QuePaxa log entry. Per-command conflicts remain isolated in
6621    /// the returned vector, whose order and length match `commands`. The whole vector is validated
6622    /// before the first write attempt, so an outer `Err` guarantees that nothing was attempted.
6623    #[cfg(feature = "kv")]
6624    #[cfg_attr(
6625        all(not(feature = "sql"), not(feature = "graph")),
6626        allow(unreachable_patterns)
6627    )]
6628    pub fn mutate_kv_batch(
6629        &self,
6630        commands: Vec<KvCommandV1>,
6631    ) -> Result<Vec<Result<KvMutationOutcome, NodeError>>, NodeError> {
6632        self.require_execution_profile(ExecutionProfile::Kv)?;
6633        validate_kv_batch_len(commands.len())?;
6634        let mut members = Vec::with_capacity(commands.len());
6635        for command in commands {
6636            let payload = encode_replicated_kv_command(&command)
6637                .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6638            validate_command_size(&payload)?;
6639            members.push(RuntimeBatchMember {
6640                #[cfg(feature = "sql")]
6641                request_id: command.request_id().to_owned(),
6642                payload,
6643                operation: QueuedOperation::Kv(command),
6644            });
6645        }
6646        Ok(self
6647            .execute_kv_group_commit(members)?
6648            .into_iter()
6649            .map(|result| {
6650                result.and_then(|response| match response {
6651                    ClientWriteResponse::Kv(outcome) => Ok(outcome),
6652                    _ => Err(NodeError::Invariant(
6653                        "KV batch returned a response for another profile".into(),
6654                    )),
6655                })
6656            })
6657            .collect())
6658    }
6659
6660    #[cfg(feature = "kv")]
6661    fn mutate_kv_payload_locked(
6662        &self,
6663        command: &KvCommandV1,
6664        payload: Vec<u8>,
6665    ) -> Result<KvMutationOutcome, NodeError> {
6666        self.ensure_ready()?;
6667        self.ensure_writes_active()?;
6668        if let Some(record) = self.check_kv_request(command.request_id(), &payload)? {
6669            return Ok(KvMutationOutcome::from_record(record));
6670        }
6671        loop {
6672            let (last_index, last_hash) = self.ensure_materialized_tip()?;
6673            let slot = last_index.checked_add(1).ok_or_else(|| {
6674                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
6675            })?;
6676            let entry = self
6677                .consensus
6678                .propose_at_cancellable(
6679                    slot,
6680                    last_hash,
6681                    Command::new(CommandKind::Deterministic, payload.clone()),
6682                    &self.operation_cancelled,
6683                )
6684                .map_err(|error| self.map_consensus_error(error))?;
6685            self.persist_entry(&entry, slot, last_hash)?;
6686            if let Some(record) = self.check_kv_request(command.request_id(), &payload)? {
6687                return Ok(KvMutationOutcome::from_record(record));
6688            }
6689            if entry.entry_type == EntryType::Command && entry.payload == payload {
6690                return Err(self.latch(NodeError::Invariant(
6691                    "committed KV request was not recorded by redb".into(),
6692                )));
6693            }
6694        }
6695    }
6696
6697    #[cfg(feature = "kv")]
6698    pub fn get_kv(
6699        &self,
6700        key: &[u8],
6701        consistency: ReadConsistency,
6702    ) -> Result<KvReadResponse, NodeError> {
6703        match consistency {
6704            ReadConsistency::Local => self.get_kv_local(key, None),
6705            ReadConsistency::AppliedIndex(required) => self.get_kv_local(key, Some(required)),
6706            ReadConsistency::ReadBarrier => {
6707                let anchor = self.establish_read_barrier()?;
6708                self.validate_read_barrier_before_snapshot(anchor)?;
6709                let response = self.get_kv_local(key, Some(anchor.index()))?;
6710                self.validate_read_barrier_snapshot(
6711                    anchor,
6712                    LogAnchor::new(response.applied_index, response.hash),
6713                )?;
6714                Ok(response)
6715            }
6716        }
6717    }
6718
6719    #[cfg(feature = "kv")]
6720    pub fn scan_kv_range(
6721        &self,
6722        start: &[u8],
6723        end: Option<&[u8]>,
6724        limit: usize,
6725        cursor: Option<&[u8]>,
6726        consistency: ReadConsistency,
6727    ) -> Result<KvScanResult, NodeError> {
6728        match consistency {
6729            ReadConsistency::Local => self.scan_kv_range_local(start, end, limit, cursor, None),
6730            ReadConsistency::AppliedIndex(required) => {
6731                self.scan_kv_range_local(start, end, limit, cursor, Some(required))
6732            }
6733            ReadConsistency::ReadBarrier => {
6734                let anchor = self.establish_read_barrier()?;
6735                self.validate_read_barrier_before_snapshot(anchor)?;
6736                let result =
6737                    self.scan_kv_range_local(start, end, limit, cursor, Some(anchor.index()))?;
6738                self.validate_read_barrier_snapshot(
6739                    anchor,
6740                    LogAnchor::new(result.tip().applied_index(), result.tip().applied_hash()),
6741                )?;
6742                Ok(result)
6743            }
6744        }
6745    }
6746
6747    #[cfg(feature = "kv")]
6748    pub fn scan_kv_prefix(
6749        &self,
6750        prefix: &[u8],
6751        limit: usize,
6752        cursor: Option<&[u8]>,
6753        consistency: ReadConsistency,
6754    ) -> Result<KvScanResult, NodeError> {
6755        match consistency {
6756            ReadConsistency::Local => self.scan_kv_prefix_local(prefix, limit, cursor, None),
6757            ReadConsistency::AppliedIndex(required) => {
6758                self.scan_kv_prefix_local(prefix, limit, cursor, Some(required))
6759            }
6760            ReadConsistency::ReadBarrier => {
6761                let anchor = self.establish_read_barrier()?;
6762                self.validate_read_barrier_before_snapshot(anchor)?;
6763                let result =
6764                    self.scan_kv_prefix_local(prefix, limit, cursor, Some(anchor.index()))?;
6765                self.validate_read_barrier_snapshot(
6766                    anchor,
6767                    LogAnchor::new(result.tip().applied_index(), result.tip().applied_hash()),
6768                )?;
6769                Ok(result)
6770            }
6771        }
6772    }
6773
6774    #[cfg(feature = "sql")]
6775    pub fn execute_sql(&self, command: SqlCommand) -> Result<WriteResponse, NodeError> {
6776        self.execute_sql_with_results(command)
6777            .map(|response| WriteResponse {
6778                applied_index: response.applied_index,
6779                hash: response.hash,
6780            })
6781    }
6782
6783    /// Executes an ordered, non-atomic batch of SQL commands.
6784    ///
6785    /// All successful, previously unseen members that fit the replicated command byte cap share
6786    /// one exact-base QWAL entry. Failed members are rolled back independently and later members
6787    /// continue. The aggregate canonical input is limited to [`MAX_COMMAND_BYTES`]. The whole
6788    /// vector is validated before the first write attempt, so an outer `Err` guarantees that
6789    /// nothing was attempted.
6790    #[cfg(feature = "sql")]
6791    pub fn execute_sql_batch(
6792        &self,
6793        commands: Vec<SqlCommand>,
6794    ) -> Result<Vec<Result<SqlExecuteResponse, NodeError>>, NodeError> {
6795        self.require_execution_profile(ExecutionProfile::Sqlite)?;
6796        validate_sql_batch_len(commands.len())?;
6797        let mut members = Vec::with_capacity(commands.len());
6798        let mut aggregate_encoded_bytes = 0_usize;
6799        for command in commands {
6800            validate_field(
6801                "request_id",
6802                &command.request_id,
6803                MAX_REQUEST_ID_BYTES,
6804                false,
6805            )?;
6806            let payload = encode_sql_command_with_index(&command)?;
6807            validate_command_size(&payload)?;
6808            aggregate_encoded_bytes = aggregate_encoded_bytes.saturating_add(payload.len());
6809            if aggregate_encoded_bytes > MAX_COMMAND_BYTES {
6810                return Err(NodeError::ResourceExhausted(format!(
6811                    "SQL write batch exceeds {MAX_COMMAND_BYTES} aggregate encoded bytes"
6812                )));
6813            }
6814            members.push(RuntimeBatchMember {
6815                request_id: command.request_id.clone(),
6816                payload,
6817                operation: QueuedOperation::Sql(command),
6818            });
6819        }
6820        let single_statement_batch = members.iter().all(|member| {
6821            matches!(
6822                &member.operation,
6823                QueuedOperation::Sql(command) if command.statements.len() == 1
6824            )
6825        });
6826        let results = if single_statement_batch {
6827            self.execute_sql_group_commit(members)?
6828        } else {
6829            self.execute_client_batch(members)
6830        };
6831        Ok(results
6832            .into_iter()
6833            .map(|result| {
6834                result.and_then(|response| match response {
6835                    ClientWriteResponse::Sql(response) => Ok(response),
6836                    _ => Err(NodeError::Invariant(
6837                        "SQL batch returned a response for another profile".into(),
6838                    )),
6839                })
6840            })
6841            .collect())
6842    }
6843
6844    #[cfg(feature = "sql")]
6845    fn execute_sql_with_results(
6846        &self,
6847        command: SqlCommand,
6848    ) -> Result<SqlExecuteResponse, NodeError> {
6849        self.execute_sql_batch(vec![command])?
6850            .into_iter()
6851            .next()
6852            .expect("one-member SQL batch returns one result")
6853    }
6854
6855    #[cfg(feature = "sql")]
6856    fn execute_sql_group_commit(&self, members: Vec<RuntimeBatchMember>) -> SqlGroupCommitResult {
6857        let (job, leader) = self
6858            .sql_group_commit
6859            .enqueue(members, &self.operation_cancelled)?;
6860        if leader {
6861            if let Some(observer) = self.config.sql_write_profiler.clone() {
6862                self.run_sql_group_commit_leader(&mut EnabledSqlWritePhaseProfile::new(observer));
6863            } else {
6864                self.run_sql_group_commit_leader(&mut DisabledSqlWritePhaseProfile);
6865            }
6866        }
6867        job.wait(&self.operation_cancelled)
6868    }
6869
6870    #[cfg(feature = "sql")]
6871    fn run_sql_group_commit_leader<P: SqlWritePhaseProfile>(&self, profile: &mut P) {
6872        let _commit = match self.lock_commit() {
6873            Ok(commit) => commit,
6874            Err(error) => {
6875                self.sql_group_commit.fail_pending(error);
6876                return;
6877            }
6878        };
6879        profile.commit_lock_acquired();
6880
6881        loop {
6882            if !self
6883                .sql_group_commit
6884                .collect_until_full_or_timeout(self.config.writer_batch_window())
6885            {
6886                break;
6887            }
6888            let Some(jobs) = self.sql_group_commit.drain_next_group() else {
6889                break;
6890            };
6891            if let Err(error) = self
6892                .ensure_ready()
6893                .and_then(|_| self.ensure_writes_active())
6894            {
6895                for job in &jobs {
6896                    job.publish(Err(error.clone()));
6897                }
6898                self.sql_group_commit.fail_pending(error);
6899                return;
6900            }
6901            let execution = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
6902                #[cfg(test)]
6903                if let Some(hook) = &self.sql_group_commit_before_execute_hook {
6904                    hook();
6905                }
6906                self.execute_sql_group_locked(&jobs, profile)
6907            }));
6908            let grouped_results = match execution {
6909                Ok(Ok(grouped_results)) => grouped_results,
6910                Ok(Err(error)) => {
6911                    let error = if matches!(error, NodeError::Unavailable(_)) {
6912                        error
6913                    } else {
6914                        self.latch(error)
6915                    };
6916                    for job in &jobs {
6917                        job.publish(Err(error.clone()));
6918                    }
6919                    self.sql_group_commit.fail_pending(error);
6920                    return;
6921                }
6922                Err(_) => {
6923                    let error =
6924                        self.latch(NodeError::Fatal("SQL group commit leader panicked".into()));
6925                    for job in &jobs {
6926                        job.publish(Err(error.clone()));
6927                    }
6928                    self.sql_group_commit.fail_pending(error);
6929                    return;
6930                }
6931            };
6932            for (job, results) in jobs.iter().zip(grouped_results) {
6933                job.publish(Ok(results));
6934            }
6935        }
6936    }
6937
6938    #[cfg(feature = "sql")]
6939    fn execute_sql_group_locked<P: SqlWritePhaseProfile>(
6940        &self,
6941        jobs: &[Arc<SqlGroupCommitJob>],
6942        profile: &mut P,
6943    ) -> Result<Vec<Vec<Result<ClientWriteResponse, NodeError>>>, NodeError> {
6944        if self.operation_cancelled.load(Ordering::Acquire) {
6945            return Err(NodeError::Unavailable(
6946                "SQL group commit cancelled during shutdown".into(),
6947            ));
6948        }
6949        let total_members = jobs.iter().map(|job| job.member_count).sum();
6950        if total_members == 0 || total_members > MAX_SQL_WRITE_BATCH_MEMBERS {
6951            return Err(NodeError::Invariant(format!(
6952                "SQL group commit drained {total_members} members outside 1..={MAX_SQL_WRITE_BATCH_MEMBERS}"
6953            )));
6954        }
6955        let mut members = Vec::with_capacity(total_members);
6956        for job in jobs {
6957            let job_members = job.take_members()?;
6958            if job_members.len() != job.member_count {
6959                return Err(NodeError::Invariant(
6960                    "SQL group commit job member count changed while queued".into(),
6961                ));
6962            }
6963            members.extend(job_members);
6964        }
6965        let results = self.execute_sql_client_batch_locked(&members, profile);
6966        if self.operation_cancelled.load(Ordering::Acquire) {
6967            return Err(NodeError::Unavailable(
6968                "SQL group commit cancelled during shutdown".into(),
6969            ));
6970        }
6971        if self.is_fatal() {
6972            return Err(NodeError::Fatal(
6973                self.fatal_reason()
6974                    .unwrap_or_else(|| "SQL group commit failed fatally".into()),
6975            ));
6976        }
6977        if results.len() != total_members {
6978            return Err(NodeError::Invariant(format!(
6979                "SQL group commit returned {} results for {total_members} members",
6980                results.len()
6981            )));
6982        }
6983        let mut results = results.into_iter();
6984        let grouped = jobs
6985            .iter()
6986            .map(|job| results.by_ref().take(job.member_count).collect::<Vec<_>>())
6987            .collect::<Vec<_>>();
6988        if results.next().is_some() || grouped.iter().map(Vec::len).sum::<usize>() != total_members
6989        {
6990            return Err(NodeError::Invariant(
6991                "SQL group commit result offsets were misaligned".into(),
6992            ));
6993        }
6994        Ok(grouped)
6995    }
6996
6997    #[cfg(feature = "sql")]
6998    fn execute_client_batch(
6999        &self,
7000        members: Vec<RuntimeBatchMember>,
7001    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7002        if self.config.execution_profile != ExecutionProfile::Sqlite {
7003            return self.execute_profile_client_batch(members);
7004        }
7005        let is_single_statement_sql = members.iter().all(|member| {
7006            matches!(
7007                &member.operation,
7008                QueuedOperation::Sql(command) if command.statements.len() == 1
7009            )
7010        });
7011        if is_single_statement_sql {
7012            if let Some(observer) = self.config.sql_write_profiler.clone() {
7013                let mut profile = EnabledSqlWritePhaseProfile::new(observer);
7014                let _commit = match self.lock_commit() {
7015                    Ok(commit) => commit,
7016                    Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7017                };
7018                profile.commit_lock_acquired();
7019                if let Err(error) = self
7020                    .ensure_ready()
7021                    .and_then(|_| self.ensure_writes_active())
7022                {
7023                    return members.into_iter().map(|_| Err(error.clone())).collect();
7024                }
7025                return self.execute_sql_client_batch_locked(&members, &mut profile);
7026            }
7027        }
7028        let _commit = match self.lock_commit() {
7029            Ok(commit) => commit,
7030            Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7031        };
7032        if let Err(error) = self
7033            .ensure_ready()
7034            .and_then(|_| self.ensure_writes_active())
7035        {
7036            return members.into_iter().map(|_| Err(error.clone())).collect();
7037        }
7038
7039        if is_single_statement_sql {
7040            return self
7041                .execute_sql_client_batch_locked(&members, &mut DisabledSqlWritePhaseProfile);
7042        }
7043
7044        members
7045            .iter()
7046            .map(|member| self.execute_single_member_locked(member))
7047            .collect()
7048    }
7049
7050    #[cfg(feature = "sql")]
7051    fn execute_sql_client_batch_locked<P: SqlWritePhaseProfile>(
7052        &self,
7053        members: &[RuntimeBatchMember],
7054        profile: &mut P,
7055    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7056        let classification_mark = profile.mark();
7057        let mut results = vec![None; members.len()];
7058        let mut pending = Vec::new();
7059        let mut canonical_by_request: HashMap<String, Vec<usize>> = HashMap::new();
7060        let mut lookup_indices = Vec::with_capacity(members.len());
7061        let mut aliases = vec![None; members.len()];
7062        let mut blocked_by = vec![None; members.len()];
7063
7064        for (index, member) in members.iter().enumerate() {
7065            let QueuedOperation::Sql(command) = &member.operation else {
7066                unreachable!("SQL batch members were validated by the caller");
7067            };
7068            let canonicals = canonical_by_request
7069                .entry(command.request_id.clone())
7070                .or_default();
7071            if let Some(canonical) = canonicals
7072                .iter()
7073                .copied()
7074                .find(|canonical| members[*canonical].payload == member.payload)
7075            {
7076                aliases[index] = Some(canonical);
7077                continue;
7078            }
7079            blocked_by[index] = canonicals.last().copied();
7080            canonicals.push(index);
7081            lookup_indices.push(index);
7082        }
7083
7084        let preflight = self.check_sql_members_bulk(members, &lookup_indices);
7085        let preflight = match preflight {
7086            Ok(preflight) => preflight,
7087            Err(error) => {
7088                profile.add_precheck_classification(classification_mark);
7089                return members.iter().map(|_| Err(error.clone())).collect();
7090            }
7091        };
7092        for (index, lookup) in preflight {
7093            match lookup {
7094                Ok(Some((outcome, sql_result))) => {
7095                    results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7096                        write_response(outcome),
7097                        Some(sql_result),
7098                    ))));
7099                }
7100                Ok(None) => pending.push(index),
7101                Err(error) => results[index] = Some(Err(error)),
7102            }
7103        }
7104        profile.add_precheck_classification(classification_mark);
7105
7106        while !pending.is_empty() {
7107            let eligible = pending
7108                .iter()
7109                .copied()
7110                .filter(|index| {
7111                    blocked_by[*index].is_none_or(|predecessor| results[predecessor].is_some())
7112                })
7113                .take(MAX_SQL_WRITE_BATCH_MEMBERS)
7114                .collect::<Vec<_>>();
7115            if eligible.is_empty() {
7116                let error = self.latch(NodeError::Invariant(
7117                    "SQL writer batch has an unresolved duplicate dependency".into(),
7118                ));
7119                for index in pending.drain(..) {
7120                    results[index] = Some(Err(error.clone()));
7121                }
7122                break;
7123            }
7124
7125            let (last_index, last_hash) = match self.ensure_materialized_tip() {
7126                Ok(tip) => tip,
7127                Err(error) => {
7128                    for index in pending.drain(..) {
7129                        results[index] = Some(Err(error.clone()));
7130                    }
7131                    break;
7132                }
7133            };
7134            let mut attempt_count = eligible.len();
7135            let (proposal_payload, prepared_results) = loop {
7136                let attempted = &eligible[..attempt_count];
7137                let batch_members = attempted
7138                    .iter()
7139                    .map(|index| {
7140                        let QueuedOperation::Sql(command) = &members[*index].operation else {
7141                            unreachable!("SQL batch members were validated above");
7142                        };
7143                        SqlBatchMember {
7144                            command,
7145                            request_payload: &members[*index].payload,
7146                        }
7147                    })
7148                    .collect::<Vec<_>>();
7149                let preparation_mark = profile.mark();
7150                let preparation = self.lock_sqlite().and_then(|sqlite| {
7151                    sqlite
7152                        .prepare_sql_batch_effect(&batch_members, last_index, last_hash)
7153                        .map_err(|error| self.map_sqlite_error(error))
7154                });
7155                profile.add_qwal_prepare(preparation_mark);
7156                let preparation = match preparation {
7157                    Ok(preparation) => preparation,
7158                    Err(NodeError::ResourceExhausted(message)) if attempt_count > 1 => {
7159                        attempt_count = attempt_count.div_ceil(2);
7160                        let _ = message;
7161                        continue;
7162                    }
7163                    Err(NodeError::ResourceExhausted(message)) => {
7164                        results[attempted[0]] = Some(Err(NodeError::ResourceExhausted(message)));
7165                        break (None, Vec::new());
7166                    }
7167                    Err(error) => {
7168                        for index in pending.drain(..) {
7169                            results[index] = Some(Err(error.clone()));
7170                        }
7171                        break (None, Vec::new());
7172                    }
7173                };
7174                if preparation.results.len() != attempted.len() {
7175                    let error = self.latch(NodeError::Invariant(
7176                        "SQLite batch preparation returned a misaligned result vector".into(),
7177                    ));
7178                    for index in pending.drain(..) {
7179                        results[index] = Some(Err(error.clone()));
7180                    }
7181                    break (None, Vec::new());
7182                }
7183
7184                let mut proposed = Vec::new();
7185                for (index, member_result) in attempted.iter().copied().zip(preparation.results) {
7186                    match member_result {
7187                        Ok(result) => proposed.push((index, result)),
7188                        Err(error) => {
7189                            results[index] = Some(Err(self.map_sql_batch_member_error(error)))
7190                        }
7191                    }
7192                }
7193                match preparation.effect {
7194                    Some(_) if proposed.is_empty() => {
7195                        let error = self.latch(NodeError::Invariant(
7196                            "SQLite prepared an effect without a successful SQL member".into(),
7197                        ));
7198                        for index in pending.drain(..) {
7199                            results[index] = Some(Err(error.clone()));
7200                        }
7201                        break (None, Vec::new());
7202                    }
7203                    Some(payload) if !payload.starts_with(QWAL_V3_MAGIC) => {
7204                        let error = self.latch(NodeError::Invariant(
7205                            "SQLite materializer prepared a non-QWAL v3 SQL batch".into(),
7206                        ));
7207                        for index in pending.drain(..) {
7208                            results[index] = Some(Err(error.clone()));
7209                        }
7210                        break (None, Vec::new());
7211                    }
7212                    Some(payload) if payload.len() <= MAX_COMMAND_BYTES => {
7213                        break (Some(payload), proposed)
7214                    }
7215                    Some(_) if attempt_count > 1 => {
7216                        for index in attempted {
7217                            results[*index] = None;
7218                        }
7219                        attempt_count = attempt_count.div_ceil(2);
7220                    }
7221                    Some(_) => {
7222                        results[attempted[0]] = Some(Err(NodeError::ResourceExhausted(format!(
7223                            "SQL effect exceeds {MAX_COMMAND_BYTES} bytes"
7224                        ))));
7225                        break (None, Vec::new());
7226                    }
7227                    None if proposed.is_empty() => break (None, Vec::new()),
7228                    None => {
7229                        let error = self.latch(NodeError::Invariant(
7230                            "SQLite omitted the effect for successful SQL members".into(),
7231                        ));
7232                        for index in pending.drain(..) {
7233                            results[index] = Some(Err(error.clone()));
7234                        }
7235                        break (None, Vec::new());
7236                    }
7237                }
7238            };
7239
7240            pending.retain(|index| results[*index].is_none());
7241            let Some(proposal_payload) = proposal_payload else {
7242                continue;
7243            };
7244            let slot = match last_index.checked_add(1) {
7245                Some(slot) => slot,
7246                None => {
7247                    let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7248                    for index in pending.drain(..) {
7249                        results[index] = Some(Err(error.clone()));
7250                    }
7251                    break;
7252                }
7253            };
7254            let consensus_mark = profile.mark();
7255            let entry = self.consensus.propose_at_cancellable(
7256                slot,
7257                last_hash,
7258                Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7259                &self.operation_cancelled,
7260            );
7261            profile.add_consensus_propose(consensus_mark);
7262            let entry = match entry {
7263                Ok(entry) => entry,
7264                Err(error) => {
7265                    let error = self.map_consensus_error(error);
7266                    for index in pending.drain(..) {
7267                        results[index] = Some(Err(error.clone()));
7268                    }
7269                    break;
7270                }
7271            };
7272            if let Err(error) = self.persist_sql_entry_profiled(&entry, slot, last_hash, profile) {
7273                for index in pending.drain(..) {
7274                    results[index] = Some(Err(error.clone()));
7275                }
7276                break;
7277            }
7278
7279            let exact_winner =
7280                entry.entry_type == EntryType::Command && entry.payload == proposal_payload;
7281            let exact_winner_member_count = exact_winner.then_some(prepared_results.len());
7282            if exact_winner {
7283                for (index, sql_result) in prepared_results {
7284                    results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7285                        WriteResponse {
7286                            applied_index: entry.index,
7287                            hash: entry.hash,
7288                        },
7289                        Some(sql_result),
7290                    ))));
7291                }
7292                pending.retain(|index| results[*index].is_none());
7293            }
7294
7295            let current_pending = std::mem::take(&mut pending);
7296            let classification_mark = profile.mark();
7297            let post_commit = self.check_sql_members_bulk(members, &current_pending);
7298            let post_commit = match post_commit {
7299                Ok(post_commit) => post_commit,
7300                Err(error) => {
7301                    for index in current_pending {
7302                        results[index] = Some(Err(error.clone()));
7303                    }
7304                    profile.add_precheck_classification(classification_mark);
7305                    if let Some(batch_member_count) = exact_winner_member_count {
7306                        profile.record_success(batch_member_count);
7307                    }
7308                    break;
7309                }
7310            };
7311            for (index, lookup) in post_commit {
7312                match lookup {
7313                    Ok(Some((outcome, sql_result))) => {
7314                        results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7315                            write_response(outcome),
7316                            Some(sql_result),
7317                        ))));
7318                    }
7319                    Ok(None) => pending.push(index),
7320                    Err(error) => results[index] = Some(Err(error)),
7321                }
7322            }
7323            profile.add_precheck_classification(classification_mark);
7324            if let Some(batch_member_count) = exact_winner_member_count {
7325                profile.record_success(batch_member_count);
7326            }
7327        }
7328
7329        for (index, canonical) in aliases.into_iter().enumerate() {
7330            if let Some(canonical) = canonical {
7331                results[index] = results[canonical].clone();
7332            }
7333        }
7334        results
7335            .into_iter()
7336            .map(|result| {
7337                result.unwrap_or_else(|| {
7338                    Err(self.latch(NodeError::Invariant(
7339                        "SQL writer batch omitted a request result".into(),
7340                    )))
7341                })
7342            })
7343            .collect()
7344    }
7345
7346    #[cfg(feature = "sql")]
7347    fn check_sql_members_bulk(
7348        &self,
7349        members: &[RuntimeBatchMember],
7350        indices: &[usize],
7351    ) -> Result<Vec<(usize, CheckedSqlRequest)>, NodeError> {
7352        if indices.is_empty() {
7353            return Ok(Vec::new());
7354        }
7355        let mut rounds = Vec::<Vec<usize>>::new();
7356        let mut occurrence_by_request = HashMap::<String, usize>::new();
7357        for index in indices {
7358            let QueuedOperation::Sql(command) = &members[*index].operation else {
7359                return Err(self.latch(NodeError::Invariant(
7360                    "non-SQL member reached SQL receipt precheck".into(),
7361                )));
7362            };
7363            let round = occurrence_by_request
7364                .entry(command.request_id.clone())
7365                .or_default();
7366            if rounds.len() == *round {
7367                rounds.push(Vec::new());
7368            }
7369            rounds[*round].push(*index);
7370            *round += 1;
7371        }
7372
7373        let sqlite = self.lock_sqlite()?;
7374        let mut checked = Vec::with_capacity(indices.len());
7375        for round in rounds {
7376            let requests = round
7377                .iter()
7378                .map(|index| {
7379                    let QueuedOperation::Sql(command) = &members[*index].operation else {
7380                        unreachable!("SQL receipt round contains only SQL members");
7381                    };
7382                    (
7383                        command.request_id.as_str(),
7384                        members[*index].payload.as_slice(),
7385                    )
7386                })
7387                .collect::<Vec<_>>();
7388            let lookups = sqlite
7389                .check_sql_requests(&requests)
7390                .map_err(|error| self.map_sqlite_error(error))?;
7391            if lookups.len() != round.len() {
7392                return Err(self.latch(NodeError::Invariant(
7393                    "SQLite bulk receipt precheck returned a misaligned result vector".into(),
7394                )));
7395            }
7396            for (index, lookup) in round.into_iter().zip(lookups) {
7397                checked.push((
7398                    index,
7399                    match lookup {
7400                        Ok(Some((outcome, Some(sql_result)))) => Ok(Some((outcome, sql_result))),
7401                        Ok(Some((_, None))) => Err(self.latch(NodeError::Invariant(
7402                            "stored SQL receipt omitted its command result".into(),
7403                        ))),
7404                        Ok(None) => Ok(None),
7405                        Err(error) => Err(self.map_sql_batch_member_error(error)),
7406                    },
7407                ));
7408            }
7409        }
7410        Ok(checked)
7411    }
7412
7413    #[cfg(feature = "kv")]
7414    fn execute_kv_group_commit(&self, members: Vec<RuntimeBatchMember>) -> KvGroupCommitResult {
7415        let (job, leader) = self
7416            .kv_group_commit
7417            .enqueue(members, &self.operation_cancelled)?;
7418        if leader {
7419            self.run_kv_group_commit_leader();
7420        }
7421        job.wait(&self.operation_cancelled)
7422    }
7423
7424    #[cfg(feature = "kv")]
7425    fn run_kv_group_commit_leader(&self) {
7426        let _commit = match self.lock_commit() {
7427            Ok(commit) => commit,
7428            Err(error) => {
7429                self.kv_group_commit.fail_pending(error);
7430                return;
7431            }
7432        };
7433
7434        loop {
7435            if !self
7436                .kv_group_commit
7437                .collect_until_full_or_timeout(self.config.writer_batch_window())
7438            {
7439                break;
7440            }
7441            let Some(jobs) = self.kv_group_commit.drain_next_group() else {
7442                break;
7443            };
7444            if let Err(error) = self
7445                .ensure_ready()
7446                .and_then(|_| self.ensure_writes_active())
7447            {
7448                for job in &jobs {
7449                    job.publish(Err(error.clone()));
7450                }
7451                self.kv_group_commit.fail_pending(error);
7452                return;
7453            }
7454            let execution = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7455                #[cfg(test)]
7456                if let Some(hook) = &self.kv_group_commit_before_execute_hook {
7457                    hook();
7458                }
7459                self.execute_kv_group_locked(&jobs)
7460            }));
7461            let grouped_results = match execution {
7462                Ok(Ok(grouped_results)) => grouped_results,
7463                Ok(Err(error)) => {
7464                    let error = if matches!(error, NodeError::Unavailable(_)) {
7465                        error
7466                    } else {
7467                        self.latch(error)
7468                    };
7469                    for job in &jobs {
7470                        job.publish(Err(error.clone()));
7471                    }
7472                    self.kv_group_commit.fail_pending(error);
7473                    return;
7474                }
7475                Err(_) => {
7476                    let error =
7477                        self.latch(NodeError::Fatal("KV group commit leader panicked".into()));
7478                    for job in &jobs {
7479                        job.publish(Err(error.clone()));
7480                    }
7481                    self.kv_group_commit.fail_pending(error);
7482                    return;
7483                }
7484            };
7485            for (job, results) in jobs.iter().zip(grouped_results) {
7486                job.publish(Ok(results));
7487            }
7488        }
7489    }
7490
7491    #[cfg(feature = "kv")]
7492    fn execute_kv_group_locked(
7493        &self,
7494        jobs: &[Arc<KvGroupCommitJob>],
7495    ) -> Result<Vec<Vec<Result<ClientWriteResponse, NodeError>>>, NodeError> {
7496        if self.operation_cancelled.load(Ordering::Acquire) {
7497            return Err(NodeError::Unavailable(
7498                "KV group commit cancelled during shutdown".into(),
7499            ));
7500        }
7501        let total_members = jobs.iter().map(|job| job.member_count).sum();
7502        if total_members == 0 || total_members > MAX_KV_GROUP_COMMIT_MEMBERS {
7503            return Err(NodeError::Invariant(format!(
7504                "KV group commit drained {total_members} members outside 1..={MAX_KV_GROUP_COMMIT_MEMBERS}"
7505            )));
7506        }
7507        let mut members = Vec::with_capacity(total_members);
7508        for job in jobs {
7509            let job_members = job.take_members()?;
7510            if job_members.len() != job.member_count {
7511                return Err(NodeError::Invariant(
7512                    "KV group commit job member count changed while queued".into(),
7513                ));
7514            }
7515            members.extend(job_members);
7516        }
7517        let results = self.execute_kv_client_batch_locked(&members);
7518        #[cfg(test)]
7519        if let Some(hook) = &self.kv_group_commit_after_execute_hook {
7520            hook(self);
7521        }
7522        if results.len() != total_members {
7523            let error = self.latch(NodeError::Invariant(format!(
7524                "KV group commit returned {} results for {total_members} members",
7525                results.len()
7526            )));
7527            return Ok(jobs
7528                .iter()
7529                .map(|job| vec![Err(error.clone()); job.member_count])
7530                .collect());
7531        }
7532        let mut results = results.into_iter();
7533        let grouped = jobs
7534            .iter()
7535            .map(|job| results.by_ref().take(job.member_count).collect::<Vec<_>>())
7536            .collect::<Vec<_>>();
7537        debug_assert!(results.next().is_none());
7538        debug_assert_eq!(grouped.iter().map(Vec::len).sum::<usize>(), total_members);
7539        Ok(grouped)
7540    }
7541
7542    #[cfg(not(feature = "sql"))]
7543    fn execute_client_batch(
7544        &self,
7545        members: Vec<RuntimeBatchMember>,
7546    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7547        self.execute_profile_client_batch(members)
7548    }
7549
7550    fn execute_profile_client_batch(
7551        &self,
7552        members: Vec<RuntimeBatchMember>,
7553    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7554        #[cfg(feature = "graph")]
7555        if self.config.execution_profile == ExecutionProfile::Graph {
7556            return self.execute_graph_client_batch(members);
7557        }
7558        #[cfg(feature = "kv")]
7559        if self.config.execution_profile == ExecutionProfile::Kv {
7560            return self.execute_kv_client_batch(members);
7561        }
7562        let _commit = match self.lock_commit() {
7563            Ok(commit) => commit,
7564            Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7565        };
7566        if let Err(error) = self
7567            .ensure_ready()
7568            .and_then(|_| self.ensure_writes_active())
7569        {
7570            return members.into_iter().map(|_| Err(error.clone())).collect();
7571        }
7572        members
7573            .iter()
7574            .map(|member| self.execute_profile_member_locked(member))
7575            .collect()
7576    }
7577
7578    #[cfg(feature = "graph")]
7579    #[cfg_attr(
7580        all(not(feature = "sql"), not(feature = "kv")),
7581        allow(irrefutable_let_patterns, unreachable_patterns)
7582    )]
7583    fn execute_graph_client_batch(
7584        &self,
7585        members: Vec<RuntimeBatchMember>,
7586    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7587        let _commit = match self.lock_commit() {
7588            Ok(commit) => commit,
7589            Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7590        };
7591        if let Err(error) = self
7592            .ensure_ready()
7593            .and_then(|_| self.ensure_writes_active())
7594        {
7595            return members.into_iter().map(|_| Err(error.clone())).collect();
7596        }
7597
7598        let mut results = vec![None; members.len()];
7599        let mut pending = Vec::new();
7600        let mut canonical_by_request = HashMap::new();
7601        let mut aliases = vec![None; members.len()];
7602        for (index, member) in members.iter().enumerate() {
7603            let QueuedOperation::Graph(command) = &member.operation else {
7604                results[index] = Some(Err(NodeError::ExecutionProfileMismatch {
7605                    expected: ExecutionProfile::Graph,
7606                    actual: ExecutionProfile::Sqlite,
7607                }));
7608                continue;
7609            };
7610            match self.check_graph_request(command.request_id(), &member.payload) {
7611                Ok(Some(record)) => {
7612                    results[index] = Some(Ok(ClientWriteResponse::Graph(
7613                        GraphMutationOutcome::from_record(record),
7614                    )));
7615                }
7616                Ok(None) => match classify_pending_request(
7617                    &mut canonical_by_request,
7618                    &members,
7619                    index,
7620                    command.request_id(),
7621                ) {
7622                    Ok(None) => pending.push(index),
7623                    Ok(Some(canonical)) => aliases[index] = Some(canonical),
7624                    Err(error) => results[index] = Some(Err(error)),
7625                },
7626                Err(error) => results[index] = Some(Err(error)),
7627            }
7628        }
7629
7630        while !pending.is_empty() {
7631            if pending.len() == 1 {
7632                let index = pending[0];
7633                results[index] = Some(self.execute_profile_member_locked(&members[index]));
7634                break;
7635            }
7636            let commands = pending
7637                .iter()
7638                .map(|index| match &members[*index].operation {
7639                    QueuedOperation::Graph(command) => command.clone(),
7640                    _ => unreachable!("graph pending members were validated above"),
7641                })
7642                .collect::<Vec<_>>();
7643            let full_payload = match encode_replicated_graph_batch(&commands) {
7644                Ok(payload) => payload,
7645                Err(error) => {
7646                    let error = NodeError::InvalidRequest(error.to_string());
7647                    for index in pending.drain(..) {
7648                        results[index] = Some(Err(error.clone()));
7649                    }
7650                    break;
7651                }
7652            };
7653            let (proposal_count, proposal_payload) = if full_payload.len() <= MAX_COMMAND_BYTES {
7654                (commands.len(), full_payload)
7655            } else {
7656                let mut prefix = None;
7657                for count in (2..commands.len()).rev() {
7658                    let payload = encode_replicated_graph_batch(&commands[..count])
7659                        .expect("the validated graph batch prefix remains valid");
7660                    if payload.len() <= MAX_COMMAND_BYTES {
7661                        prefix = Some((count, payload));
7662                        break;
7663                    }
7664                }
7665                let Some(prefix) = prefix else {
7666                    let index = pending.remove(0);
7667                    results[index] = Some(self.execute_profile_member_locked(&members[index]));
7668                    continue;
7669                };
7670                prefix
7671            };
7672            let proposed_indices = pending[..proposal_count].to_vec();
7673            let (last_index, last_hash) = match self.ensure_materialized_tip() {
7674                Ok(tip) => tip,
7675                Err(error) => {
7676                    for index in pending.drain(..) {
7677                        results[index] = Some(Err(error.clone()));
7678                    }
7679                    break;
7680                }
7681            };
7682            let slot = match last_index.checked_add(1) {
7683                Some(slot) => slot,
7684                None => {
7685                    let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7686                    for index in pending.drain(..) {
7687                        results[index] = Some(Err(error.clone()));
7688                    }
7689                    break;
7690                }
7691            };
7692            let entry = match self.consensus.propose_at_cancellable(
7693                slot,
7694                last_hash,
7695                Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7696                &self.operation_cancelled,
7697            ) {
7698                Ok(entry) => entry,
7699                Err(error) => {
7700                    let error = self.map_consensus_error(error);
7701                    for index in pending.drain(..) {
7702                        results[index] = Some(Err(error.clone()));
7703                    }
7704                    break;
7705                }
7706            };
7707            if let Err(error) = self.persist_entry(&entry, slot, last_hash) {
7708                for index in pending.drain(..) {
7709                    results[index] = Some(Err(error.clone()));
7710                }
7711                break;
7712            }
7713
7714            let mut remaining = Vec::new();
7715            for index in pending.drain(..) {
7716                let member = &members[index];
7717                let QueuedOperation::Graph(command) = &member.operation else {
7718                    unreachable!("graph pending members were validated above");
7719                };
7720                match self.check_graph_request(command.request_id(), &member.payload) {
7721                    Ok(Some(record)) => {
7722                        results[index] = Some(Ok(ClientWriteResponse::Graph(
7723                            GraphMutationOutcome::from_record(record),
7724                        )));
7725                    }
7726                    Ok(None) => remaining.push(index),
7727                    Err(error) => results[index] = Some(Err(error)),
7728                }
7729            }
7730            if entry.entry_type == EntryType::Command
7731                && entry.payload == proposal_payload
7732                && remaining
7733                    .iter()
7734                    .any(|index| proposed_indices.contains(index))
7735            {
7736                let error = self.latch(NodeError::Invariant(
7737                    "committed graph batch did not record every request".into(),
7738                ));
7739                for index in remaining.drain(..) {
7740                    results[index] = Some(Err(error.clone()));
7741                }
7742            }
7743            pending = remaining;
7744        }
7745
7746        for (index, canonical) in aliases.into_iter().enumerate() {
7747            if let Some(canonical) = canonical {
7748                results[index] = results[canonical].clone();
7749            }
7750        }
7751
7752        results
7753            .into_iter()
7754            .map(|result| {
7755                result.unwrap_or_else(|| {
7756                    Err(self.latch(NodeError::Invariant(
7757                        "graph writer batch omitted a request result".into(),
7758                    )))
7759                })
7760            })
7761            .collect()
7762    }
7763
7764    #[cfg(feature = "kv")]
7765    #[cfg_attr(
7766        all(not(feature = "sql"), not(feature = "graph")),
7767        allow(irrefutable_let_patterns, unreachable_patterns)
7768    )]
7769    fn execute_kv_client_batch(
7770        &self,
7771        members: Vec<RuntimeBatchMember>,
7772    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7773        let _commit = match self.lock_commit() {
7774            Ok(commit) => commit,
7775            Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7776        };
7777        if let Err(error) = self
7778            .ensure_ready()
7779            .and_then(|_| self.ensure_writes_active())
7780        {
7781            return members.into_iter().map(|_| Err(error.clone())).collect();
7782        }
7783        self.execute_kv_client_batch_locked(&members)
7784    }
7785
7786    #[cfg(feature = "kv")]
7787    #[cfg_attr(
7788        all(not(feature = "sql"), not(feature = "graph")),
7789        allow(irrefutable_let_patterns, unreachable_patterns)
7790    )]
7791    fn execute_kv_client_batch_locked(
7792        &self,
7793        members: &[RuntimeBatchMember],
7794    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7795        let mut results = vec![None; members.len()];
7796        let mut pending = Vec::new();
7797        let mut canonical_by_request = HashMap::new();
7798        let mut aliases = vec![None; members.len()];
7799        let mut lookup_indices = Vec::with_capacity(members.len());
7800        for (index, member) in members.iter().enumerate() {
7801            let QueuedOperation::Kv(command) = &member.operation else {
7802                results[index] = Some(Err(NodeError::ExecutionProfileMismatch {
7803                    expected: ExecutionProfile::Kv,
7804                    actual: ExecutionProfile::Sqlite,
7805                }));
7806                continue;
7807            };
7808            match classify_pending_request(
7809                &mut canonical_by_request,
7810                members,
7811                index,
7812                command.request_id(),
7813            ) {
7814                Ok(None) => lookup_indices.push(index),
7815                Ok(Some(canonical)) => aliases[index] = Some(canonical),
7816                Err(error) => results[index] = Some(Err(error)),
7817            }
7818        }
7819        let preflight = match self.check_kv_members_bulk(members, &lookup_indices) {
7820            Ok(preflight) => preflight,
7821            Err(error) => return members.iter().map(|_| Err(error.clone())).collect(),
7822        };
7823        for (index, lookup) in preflight {
7824            match lookup {
7825                Ok(Some(record)) => {
7826                    results[index] = Some(Ok(ClientWriteResponse::Kv(
7827                        KvMutationOutcome::from_record(record),
7828                    )));
7829                }
7830                Ok(None) => pending.push(index),
7831                Err(error) => results[index] = Some(Err(error)),
7832            }
7833        }
7834
7835        while !pending.is_empty() {
7836            if pending.len() == 1 {
7837                let index = pending[0];
7838                results[index] = Some(self.execute_profile_member_locked(&members[index]));
7839                break;
7840            }
7841            let commands = pending
7842                .iter()
7843                .map(|index| match &members[*index].operation {
7844                    QueuedOperation::Kv(command) => command.clone(),
7845                    _ => unreachable!("KV pending members were validated above"),
7846                })
7847                .collect::<Vec<_>>();
7848            let full_payload = match encode_replicated_kv_batch(&commands) {
7849                Ok(payload) => payload,
7850                Err(error) => {
7851                    let error = NodeError::InvalidRequest(error.to_string());
7852                    for index in pending.drain(..) {
7853                        results[index] = Some(Err(error.clone()));
7854                    }
7855                    break;
7856                }
7857            };
7858            let (proposal_count, proposal_payload) = if full_payload.len() <= MAX_COMMAND_BYTES {
7859                (commands.len(), full_payload)
7860            } else {
7861                let Some(prefix) = largest_fitting_kv_batch_prefix(&commands) else {
7862                    let index = pending.remove(0);
7863                    results[index] = Some(self.execute_profile_member_locked(&members[index]));
7864                    continue;
7865                };
7866                prefix
7867            };
7868            let proposed_indices = pending[..proposal_count].to_vec();
7869            let (last_index, last_hash) = match self.ensure_materialized_tip() {
7870                Ok(tip) => tip,
7871                Err(error) => {
7872                    for index in pending.drain(..) {
7873                        results[index] = Some(Err(error.clone()));
7874                    }
7875                    break;
7876                }
7877            };
7878            let slot = match last_index.checked_add(1) {
7879                Some(slot) => slot,
7880                None => {
7881                    let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7882                    for index in pending.drain(..) {
7883                        results[index] = Some(Err(error.clone()));
7884                    }
7885                    break;
7886                }
7887            };
7888            let entry = match self.consensus.propose_at_cancellable(
7889                slot,
7890                last_hash,
7891                Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7892                &self.operation_cancelled,
7893            ) {
7894                Ok(entry) => entry,
7895                Err(error) => {
7896                    let error = self.map_consensus_error(error);
7897                    for index in pending.drain(..) {
7898                        results[index] = Some(Err(error.clone()));
7899                    }
7900                    break;
7901                }
7902            };
7903            if let Err(error) = self.persist_entry(&entry, slot, last_hash) {
7904                for index in pending.drain(..) {
7905                    results[index] = Some(Err(error.clone()));
7906                }
7907                break;
7908            }
7909
7910            let current_pending = std::mem::take(&mut pending);
7911            let post_commit = match self.check_kv_members_bulk(members, &current_pending) {
7912                Ok(post_commit) => post_commit,
7913                Err(error) => {
7914                    for index in current_pending {
7915                        results[index] = Some(Err(error.clone()));
7916                    }
7917                    break;
7918                }
7919            };
7920            let mut remaining = Vec::new();
7921            for (index, lookup) in post_commit {
7922                match lookup {
7923                    Ok(Some(record)) => {
7924                        results[index] = Some(Ok(ClientWriteResponse::Kv(
7925                            KvMutationOutcome::from_record(record),
7926                        )));
7927                    }
7928                    Ok(None) => remaining.push(index),
7929                    Err(error) => results[index] = Some(Err(error)),
7930                }
7931            }
7932            if entry.entry_type == EntryType::Command
7933                && entry.payload == proposal_payload
7934                && remaining
7935                    .iter()
7936                    .any(|index| proposed_indices.contains(index))
7937            {
7938                let error = self.latch(NodeError::Invariant(
7939                    "committed KV batch did not record every request".into(),
7940                ));
7941                for index in remaining.drain(..) {
7942                    results[index] = Some(Err(error.clone()));
7943                }
7944            }
7945            pending = remaining;
7946        }
7947
7948        for (index, canonical) in aliases.into_iter().enumerate() {
7949            if let Some(canonical) = canonical {
7950                results[index] = results[canonical].clone();
7951            }
7952        }
7953
7954        results
7955            .into_iter()
7956            .map(|result| {
7957                result.unwrap_or_else(|| {
7958                    Err(self.latch(NodeError::Invariant(
7959                        "KV writer batch omitted a request result".into(),
7960                    )))
7961                })
7962            })
7963            .collect()
7964    }
7965
7966    #[cfg(feature = "kv")]
7967    #[allow(clippy::infallible_destructuring_match)]
7968    fn check_kv_members_bulk(
7969        &self,
7970        members: &[RuntimeBatchMember],
7971        indices: &[usize],
7972    ) -> Result<Vec<KvMemberCheck>, NodeError> {
7973        if indices.is_empty() {
7974            return Ok(Vec::new());
7975        }
7976        let materializer = self.lock_materializer()?;
7977        let kv = match &*materializer {
7978            Materializer::Kv(kv) => kv,
7979            #[cfg(feature = "sql")]
7980            Materializer::Sql(_) => {
7981                return Err(NodeError::ExecutionProfileMismatch {
7982                    expected: ExecutionProfile::Kv,
7983                    actual: ExecutionProfile::Sqlite,
7984                });
7985            }
7986            #[cfg(feature = "graph")]
7987            Materializer::Graph(_) => {
7988                return Err(NodeError::ExecutionProfileMismatch {
7989                    expected: ExecutionProfile::Kv,
7990                    actual: ExecutionProfile::Graph,
7991                });
7992            }
7993        };
7994        let requests = indices
7995            .iter()
7996            .map(|index| {
7997                let command = match &members[*index].operation {
7998                    QueuedOperation::Kv(command) => command,
7999                    #[cfg(feature = "sql")]
8000                    QueuedOperation::KeyValue { .. } | QueuedOperation::Sql(_) => {
8001                        unreachable!("KV receipt lookup contains only KV members")
8002                    }
8003                    #[cfg(feature = "graph")]
8004                    QueuedOperation::Graph(_) => {
8005                        unreachable!("KV receipt lookup contains only KV members")
8006                    }
8007                };
8008                (command.request_id(), members[*index].payload.as_slice())
8009            })
8010            .collect::<Vec<_>>();
8011        let lookups = kv
8012            .check_requests(&requests)
8013            .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
8014        if lookups.len() != indices.len() {
8015            return Err(self.latch(NodeError::Invariant(
8016                "KV bulk receipt lookup returned a misaligned result vector".into(),
8017            )));
8018        }
8019        Ok(indices
8020            .iter()
8021            .copied()
8022            .zip(
8023                lookups.into_iter().map(|lookup| {
8024                    lookup.map_err(|error| NodeError::InvalidRequest(error.to_string()))
8025                }),
8026            )
8027            .collect())
8028    }
8029
8030    fn execute_profile_member_locked(
8031        &self,
8032        member: &RuntimeBatchMember,
8033    ) -> Result<ClientWriteResponse, NodeError> {
8034        match &member.operation {
8035            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
8036            QueuedOperation::Unavailable => unreachable!("no execution profiles are compiled in"),
8037            #[cfg(feature = "graph")]
8038            QueuedOperation::Graph(command) => self
8039                .mutate_graph_payload_locked(command, member.payload.clone())
8040                .map(ClientWriteResponse::Graph),
8041            #[cfg(feature = "kv")]
8042            QueuedOperation::Kv(command) => self
8043                .mutate_kv_payload_locked(command, member.payload.clone())
8044                .map(ClientWriteResponse::Kv),
8045            #[cfg(feature = "sql")]
8046            QueuedOperation::KeyValue { .. } | QueuedOperation::Sql(_) => {
8047                Err(NodeError::ExecutionProfileMismatch {
8048                    expected: self.config.execution_profile,
8049                    actual: ExecutionProfile::Sqlite,
8050                })
8051            }
8052        }
8053    }
8054
8055    #[cfg(feature = "sql")]
8056    fn execute_single_member_locked(
8057        &self,
8058        member: &RuntimeBatchMember,
8059    ) -> Result<ClientWriteResponse, NodeError> {
8060        if let QueuedOperation::Sql(command) = &member.operation {
8061            self.execute_sql_payload_locked(command, member.payload.clone())
8062                .map(|outcome| {
8063                    ClientWriteResponse::Sql(sql_execute_response(
8064                        outcome.response,
8065                        outcome.sql_result,
8066                    ))
8067                })
8068        } else if let QueuedOperation::KeyValue { key, value } = &member.operation {
8069            self.execute_put_payload_locked(&member.request_id, key, value, member.payload.clone())
8070                .map(|outcome| ClientWriteResponse::KeyValue(outcome.response))
8071        } else {
8072            Err(NodeError::ExecutionProfileMismatch {
8073                expected: ExecutionProfile::Sqlite,
8074                actual: self.config.execution_profile,
8075            })
8076        }
8077    }
8078
8079    #[cfg(feature = "sql")]
8080    fn execute_sql_payload_locked(
8081        &self,
8082        command: &SqlCommand,
8083        request_payload: Vec<u8>,
8084    ) -> Result<ExecutedPayload, NodeError> {
8085        self.ensure_ready()?;
8086        self.ensure_writes_active()?;
8087        if let Some(outcome) = self.check_request(&command.request_id, &request_payload)? {
8088            return Ok(ExecutedPayload {
8089                response: write_response(outcome),
8090                sql_result: self.replay_sql_result(
8091                    &command.request_id,
8092                    &request_payload,
8093                    outcome,
8094                )?,
8095            });
8096        }
8097
8098        loop {
8099            let (last_index, last_hash) = self.ensure_materialized_tip()?;
8100            let proposal_payload =
8101                self.prepare_sql_proposal(command, &request_payload, last_index, last_hash)?;
8102            let slot = last_index.checked_add(1).ok_or_else(|| {
8103                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
8104            })?;
8105            let entry = self
8106                .consensus
8107                .propose_at_cancellable(
8108                    slot,
8109                    last_hash,
8110                    Command::new(CommandKind::Deterministic, proposal_payload.clone()),
8111                    &self.operation_cancelled,
8112                )
8113                .map_err(|error| self.map_consensus_error(error))?;
8114            let sql_result = self.persist_entry(&entry, slot, last_hash)?;
8115            if let Some(outcome) = self.check_request(&command.request_id, &request_payload)? {
8116                return Ok(ExecutedPayload {
8117                    response: write_response(outcome),
8118                    sql_result: sql_result.or(self.replay_sql_result(
8119                        &command.request_id,
8120                        &request_payload,
8121                        outcome,
8122                    )?),
8123                });
8124            }
8125            if entry.entry_type == EntryType::Command && entry.payload == proposal_payload {
8126                return Err(self.latch(NodeError::Invariant(
8127                    "committed SQL request was not recorded by SQLite".into(),
8128                )));
8129            }
8130        }
8131    }
8132
8133    #[cfg(feature = "sql")]
8134    fn prepare_sql_proposal(
8135        &self,
8136        command: &SqlCommand,
8137        request_payload: &[u8],
8138        base_index: LogIndex,
8139        base_hash: LogHash,
8140    ) -> Result<Vec<u8>, NodeError> {
8141        let sqlite = self.lock_sqlite()?;
8142        let preparation = sqlite.prepare_sql_batch_effect(
8143            &[SqlBatchMember {
8144                command,
8145                request_payload,
8146            }],
8147            base_index,
8148            base_hash,
8149        );
8150        let preparation = match preparation {
8151            Ok(preparation) => preparation,
8152            Err(rhiza_sql::Error::ResourceExhausted(message)) => {
8153                return Err(NodeError::ResourceExhausted(message));
8154            }
8155            Err(error) => return Err(self.map_sqlite_error(error)),
8156        };
8157        let result = preparation
8158            .results
8159            .into_iter()
8160            .next()
8161            .expect("one-member SQL preparation returns one result");
8162        if let Err(error) = result {
8163            if let rhiza_sql::Error::ResourceExhausted(message) = error {
8164                return Err(NodeError::ResourceExhausted(message));
8165            }
8166            if let rhiza_sql::Error::RequestConflict(conflict) = error {
8167                return Err(NodeError::RequestConflict(conflict));
8168            }
8169            let message = error.to_string();
8170            let statement_index = first_invalid_sql_statement(command, |prefix| {
8171                let Ok(prefix_payload) = encode_sql_command(prefix) else {
8172                    return true;
8173                };
8174                let prefix_member = [SqlBatchMember {
8175                    command: prefix,
8176                    request_payload: &prefix_payload,
8177                }];
8178                match sqlite.prepare_sql_batch_effect(&prefix_member, base_index, base_hash) {
8179                    Ok(preparation) => preparation
8180                        .results
8181                        .into_iter()
8182                        .next()
8183                        .is_none_or(|result| result.is_err()),
8184                    Err(_) => true,
8185                }
8186            });
8187            return match statement_index {
8188                Some(statement_index) => Err(NodeError::InvalidSqlStatement {
8189                    statement_index,
8190                    message,
8191                }),
8192                None => Err(NodeError::InvalidRequest(message)),
8193            };
8194        }
8195        let payload = preparation.effect.ok_or_else(|| {
8196            self.latch(NodeError::Invariant(
8197                "successful SQL preparation omitted its QWAL v3 effect".into(),
8198            ))
8199        })?;
8200        if !payload.starts_with(QWAL_V3_MAGIC) {
8201            return Err(self.latch(NodeError::Invariant(
8202                "SQLite materializer prepared a non-QWAL v3 SQL proposal".into(),
8203            )));
8204        }
8205        if payload.len() > MAX_COMMAND_BYTES {
8206            return Err(NodeError::ResourceExhausted(format!(
8207                "SQL effect exceeds {MAX_COMMAND_BYTES} bytes"
8208            )));
8209        }
8210        Ok(payload)
8211    }
8212
8213    #[cfg(feature = "sql")]
8214    fn execute_put_payload_locked(
8215        &self,
8216        request_id: &str,
8217        key: &str,
8218        value: &str,
8219        payload: Vec<u8>,
8220    ) -> Result<ExecutedPayload, NodeError> {
8221        self.ensure_ready()?;
8222        self.ensure_writes_active()?;
8223
8224        if let Some(outcome) = self.check_request(request_id, &payload)? {
8225            return Ok(ExecutedPayload {
8226                response: write_response(outcome),
8227                sql_result: None,
8228            });
8229        }
8230
8231        loop {
8232            let (last_index, last_hash) = self.ensure_materialized_tip()?;
8233            let proposal_payload = self
8234                .lock_sqlite()?
8235                .prepare_put_effect(request_id, key, value, &payload, last_index, last_hash)
8236                .map_err(|error| self.map_sqlite_error(error))?;
8237            if !proposal_payload.starts_with(QWAL_V3_MAGIC) {
8238                return Err(self.latch(NodeError::Invariant(
8239                    "SQLite materializer prepared a non-QWAL v3 key/value write proposal".into(),
8240                )));
8241            }
8242            if proposal_payload.len() > MAX_COMMAND_BYTES {
8243                return Err(NodeError::ResourceExhausted(format!(
8244                    "SQLite QWAL effect exceeds {MAX_COMMAND_BYTES} bytes"
8245                )));
8246            }
8247            let slot = last_index.checked_add(1).ok_or_else(|| {
8248                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
8249            })?;
8250            let entry = self
8251                .consensus
8252                .propose_at_cancellable(
8253                    slot,
8254                    last_hash,
8255                    Command::new(CommandKind::Deterministic, proposal_payload.clone()),
8256                    &self.operation_cancelled,
8257                )
8258                .map_err(|error| self.map_consensus_error(error))?;
8259            self.persist_entry(&entry, slot, last_hash)?;
8260
8261            if let Some(outcome) = self.check_request(request_id, &payload)? {
8262                return Ok(ExecutedPayload {
8263                    response: write_response(outcome),
8264                    sql_result: None,
8265                });
8266            }
8267            if entry.entry_type == EntryType::Command && entry.payload == proposal_payload {
8268                return Err(self.latch(NodeError::Invariant(
8269                    "committed key/value write request was not recorded by SQLite QWAL".into(),
8270                )));
8271            }
8272        }
8273    }
8274
8275    #[cfg(feature = "sql")]
8276    fn replay_sql_result(
8277        &self,
8278        request_id: &str,
8279        payload: &[u8],
8280        outcome: RequestOutcome,
8281    ) -> Result<Option<SqlCommandResult>, NodeError> {
8282        let sqlite = self.lock_sqlite()?;
8283        let stored = sqlite
8284            .check_sql_request(request_id, payload)
8285            .map_err(|error| self.map_sqlite_error(error))?
8286            .ok_or_else(|| {
8287                self.latch(NodeError::Invariant(
8288                    "committed SQL request result is missing".into(),
8289                ))
8290            })?;
8291        if stored.0 != outcome {
8292            return Err(self.latch(NodeError::Invariant(
8293                "stored SQL request outcome changed".into(),
8294            )));
8295        }
8296        Ok(stored.1)
8297    }
8298
8299    #[cfg(feature = "sql")]
8300    fn map_sql_batch_member_error(&self, error: rhiza_sql::Error) -> NodeError {
8301        match error {
8302            rhiza_sql::Error::RequestConflict(conflict) => NodeError::RequestConflict(conflict),
8303            rhiza_sql::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
8304            rhiza_sql::Error::InvalidCommand(message) | rhiza_sql::Error::Sqlite(message) => {
8305                NodeError::InvalidSqlStatement {
8306                    statement_index: 0,
8307                    message,
8308                }
8309            }
8310            other => self.map_sqlite_error(other),
8311        }
8312    }
8313
8314    #[cfg(feature = "sql")]
8315    pub fn read(&self, key: &str, consistency: ReadConsistency) -> Result<ReadResponse, NodeError> {
8316        validate_key(key)?;
8317        match consistency {
8318            ReadConsistency::Local => self.read_local(key, None),
8319            ReadConsistency::AppliedIndex(required) => self.read_local(key, Some(required)),
8320            ReadConsistency::ReadBarrier => {
8321                let anchor = self.establish_read_barrier()?;
8322                let _commit = self.lock_commit()?;
8323                self.ensure_ready()?;
8324                self.ensure_writes_active()?;
8325                self.validate_read_barrier_descendant_locked(anchor)?;
8326                self.read_local(key, Some(anchor.index()))
8327            }
8328        }
8329    }
8330
8331    #[cfg(feature = "sql")]
8332    pub fn query_sql(
8333        &self,
8334        statement: &SqlStatement,
8335        consistency: ReadConsistency,
8336        max_rows: u32,
8337    ) -> Result<SqlQueryResponse, NodeError> {
8338        if max_rows == 0 || max_rows > MAX_SQL_MAX_ROWS {
8339            return Err(NodeError::InvalidRequest(format!(
8340                "max_rows must be between 1 and {MAX_SQL_MAX_ROWS}"
8341            )));
8342        }
8343        match consistency {
8344            ReadConsistency::Local => self.query_sql_local(statement, None, max_rows),
8345            ReadConsistency::AppliedIndex(required) => {
8346                self.query_sql_local(statement, Some(required), max_rows)
8347            }
8348            ReadConsistency::ReadBarrier => {
8349                let anchor = self.establish_read_barrier()?;
8350                let _commit = self.lock_commit()?;
8351                self.ensure_ready()?;
8352                self.ensure_writes_active()?;
8353                self.validate_read_barrier_descendant_locked(anchor)?;
8354                self.query_sql_local(statement, Some(anchor.index()), max_rows)
8355            }
8356        }
8357    }
8358
8359    pub fn applied_index(&self) -> Result<LogIndex, NodeError> {
8360        self.ensure_ready()?;
8361        self.lock_materializer()?
8362            .applied_index()
8363            .map_err(|error| self.latch(NodeError::Storage(error)))
8364    }
8365
8366    pub fn applied_hash(&self) -> Result<LogHash, NodeError> {
8367        self.ensure_ready()?;
8368        self.lock_materializer()?
8369            .applied_hash()
8370            .map_err(|error| self.latch(NodeError::Storage(error)))
8371    }
8372
8373    pub fn cancel_operations(&self) {
8374        self.operation_cancelled.store(true, Ordering::Release);
8375        #[cfg(feature = "sql")]
8376        self.sql_group_commit.fail_pending(NodeError::Unavailable(
8377            "SQL group commit cancelled during shutdown".into(),
8378        ));
8379        #[cfg(feature = "kv")]
8380        self.kv_group_commit.fail_pending(NodeError::Unavailable(
8381            "KV group commit cancelled during shutdown".into(),
8382        ));
8383        self.read_barriers.cancel_waiters();
8384        self.operation_cancelled_notify.notify_waiters();
8385    }
8386
8387    pub fn materialize_next_decision(&self) -> Result<bool, NodeError> {
8388        let _commit = self.lock_commit()?;
8389        self.ensure_ready()?;
8390        let (last_index, last_hash) = self.ensure_materialized_tip()?;
8391        let slot = last_index
8392            .checked_add(1)
8393            .ok_or_else(|| self.latch(NodeError::Invariant("qlog index is exhausted".into())))?;
8394        match self
8395            .consensus
8396            .inspect_decision_at(slot, last_hash)
8397            .map_err(|error| self.map_consensus_error(error))?
8398        {
8399            DecisionInspection::Committed(entry) => {
8400                self.persist_entry(&entry, slot, last_hash)?;
8401                Ok(true)
8402            }
8403            DecisionInspection::Empty | DecisionInspection::Pending => Ok(false),
8404            DecisionInspection::Unavailable => Err(NodeError::Unavailable(
8405                "decision proof inspection did not reach quorum".into(),
8406            )),
8407        }
8408    }
8409
8410    pub async fn run_background_materializer<F>(
8411        self: Arc<Self>,
8412        poll_interval: Duration,
8413        shutdown: F,
8414    ) -> Result<(), NodeError>
8415    where
8416        F: std::future::Future<Output = ()> + Send,
8417    {
8418        let poll_interval = poll_interval.max(Duration::from_millis(10));
8419        tokio::pin!(shutdown);
8420        loop {
8421            tokio::select! {
8422                () = &mut shutdown => return Ok(()),
8423                () = tokio::time::sleep(poll_interval) => {
8424                    loop {
8425                        let runtime = Arc::clone(&self);
8426                        let mut operation = tokio::task::spawn_blocking(move || runtime.materialize_next_decision());
8427                        let (result, shutting_down) = tokio::select! {
8428                            () = &mut shutdown => {
8429                                self.cancel_operations();
8430                                (operation.await, true)
8431                            }
8432                            result = &mut operation => (result, false),
8433                        };
8434                        if shutting_down {
8435                            return match result {
8436                                Ok(Ok(_) | Err(NodeError::Unavailable(_) | NodeError::Contention(_))) => Ok(()),
8437                                Ok(Err(error)) => Err(error),
8438                                Err(error) => Err(NodeError::Fatal(format!("materializer task failed: {error}"))),
8439                            };
8440                        }
8441                        match result {
8442                            Ok(Ok(true)) => continue,
8443                            Ok(Ok(false) | Err(NodeError::Unavailable(_) | NodeError::Contention(_))) => break,
8444                            Ok(Err(error)) => return Err(error),
8445                            Err(error) => return Err(NodeError::Fatal(format!("materializer task failed: {error}"))),
8446                        }
8447                    }
8448                }
8449            }
8450        }
8451    }
8452
8453    pub const fn config(&self) -> &NodeConfig {
8454        &self.config
8455    }
8456
8457    pub const fn consensus(&self) -> &Arc<ThreeNodeConsensus> {
8458        &self.consensus
8459    }
8460
8461    pub const fn log_store(&self) -> &FileLogStore {
8462        &self.log_store
8463    }
8464
8465    pub fn configuration_state(&self) -> Result<ConfigurationState, NodeError> {
8466        self.log_store
8467            .configuration_state()
8468            .map_err(|error| NodeError::Storage(error.to_string()))
8469    }
8470
8471    pub fn status(&self) -> Result<NodeStatus, NodeError> {
8472        let configuration_state = self.configuration_state()?;
8473        let (configuration_status, active_config_id) = if configuration_state.is_active() {
8474            (
8475                RuntimeConfigurationStatus::Active,
8476                configuration_state.config_id(),
8477            )
8478        } else if configuration_state.config_id() == self.consensus.config_id() {
8479            (
8480                RuntimeConfigurationStatus::Stopped,
8481                configuration_state.config_id(),
8482            )
8483        } else {
8484            (
8485                RuntimeConfigurationStatus::AwaitingActivation,
8486                configuration_state
8487                    .config_id()
8488                    .checked_add(1)
8489                    .ok_or_else(|| {
8490                        NodeError::Invariant("successor configuration id is exhausted".into())
8491                    })?,
8492            )
8493        };
8494        Ok(NodeStatus {
8495            ready: self.is_ready(),
8496            stop_anchor: configuration_state.stop().copied(),
8497            active_config_id,
8498            active_membership_digest: self.config.membership.digest(),
8499            configuration_status,
8500            configuration_state,
8501        })
8502    }
8503
8504    pub fn stop_current_configuration_for_successor(
8505        &self,
8506        successor: &Membership,
8507    ) -> Result<StopInformation, NodeError> {
8508        let _commit = self.lock_commit()?;
8509        self.stop_current_configuration_locked(successor)
8510    }
8511
8512    fn stop_current_configuration_locked(
8513        &self,
8514        successor: &Membership,
8515    ) -> Result<StopInformation, NodeError> {
8516        self.ensure_ready()?;
8517        self.ensure_writes_active()?;
8518        let state = self.configuration_state()?;
8519        let stop_command = ConfigChange::bound_stop(
8520            self.config.cluster_id.clone(),
8521            state.config_id(),
8522            state.digest(),
8523            state
8524                .config_id()
8525                .checked_add(1)
8526                .ok_or_else(|| NodeError::Invariant("successor config id is exhausted".into()))?,
8527            successor.members().to_vec(),
8528        )
8529        .map_err(|error| NodeError::Invariant(error.to_string()))?
8530        .to_stored_command();
8531        loop {
8532            let (last_index, last_hash) = self.ensure_materialized_tip()?;
8533            let slot = last_index
8534                .checked_add(1)
8535                .ok_or_else(|| NodeError::Invariant("qlog index is exhausted".into()))?;
8536            let entry = self
8537                .consensus
8538                .propose_stored_at(slot, last_hash, stop_command.clone())
8539                .map_err(|error| self.map_consensus_error(error))?;
8540            self.persist_entry(&entry, slot, last_hash)?;
8541            let decided = StoredCommand::new(entry.entry_type, entry.payload.clone());
8542            if decided != stop_command {
8543                let current = self.configuration_state()?;
8544                if current.is_active() {
8545                    continue;
8546                }
8547                return Err(NodeError::ConfigurationTransition {
8548                    state: Box::new(current),
8549                });
8550            }
8551            let proof = self
8552                .consensus
8553                .inspect_decision_proof_at(slot)
8554                .map_err(|error| self.map_consensus_error(error))?
8555                .ok_or_else(|| {
8556                    NodeError::Unavailable("durable Stop proof is unavailable".into())
8557                })?;
8558            if proof
8559                .proposal()
8560                .value
8561                .as_ref()
8562                .map(|value| value.entry_hash)
8563                != Some(entry.hash)
8564            {
8565                return Err(self.latch(NodeError::Reconciliation(
8566                    "Stop proof differs from committed stop entry".into(),
8567                )));
8568            }
8569            return Ok(StopInformation { entry, proof });
8570        }
8571    }
8572
8573    pub fn activate_successor(&self) -> Result<LogEntry, NodeError> {
8574        let _commit = self.lock_commit()?;
8575        self.activate_successor_locked(None)
8576    }
8577
8578    pub fn activate_successor_if(&self, expected_config_id: u64) -> Result<LogEntry, NodeError> {
8579        let _commit = self.lock_commit()?;
8580        self.activate_successor_locked(Some(expected_config_id))
8581    }
8582
8583    fn activate_successor_locked(
8584        &self,
8585        expected_config_id: Option<u64>,
8586    ) -> Result<LogEntry, NodeError> {
8587        self.ensure_ready()?;
8588        let state = self.configuration_state()?;
8589        let stop = state
8590            .stop()
8591            .copied()
8592            .ok_or_else(|| NodeError::ConfigurationTransition {
8593                state: Box::new(state.clone()),
8594            })?;
8595        if state.config_id() == self.consensus.config_id() {
8596            return Err(NodeError::ConfigurationTransition {
8597                state: Box::new(state),
8598            });
8599        }
8600        let successor_config_id = state.config_id().checked_add(1).ok_or_else(|| {
8601            NodeError::Invariant("successor configuration id is exhausted".into())
8602        })?;
8603        if expected_config_id.is_some_and(|expected| expected != successor_config_id) {
8604            return Err(NodeError::PreconditionFailed(
8605                "successor configuration does not match expected_config_id".into(),
8606            ));
8607        }
8608        let stop_entry = self.recover_stop_entry(stop)?;
8609        let entry = self
8610            .consensus
8611            .propose_activation_for_stop_entry(&stop_entry)
8612            .map_err(|error| self.map_consensus_error(error))?;
8613        self.persist_entry(&entry, stop.index() + 1, stop.hash())?;
8614        Ok(entry)
8615    }
8616
8617    pub(crate) fn recover_stop_entry(&self, stop: LogAnchor) -> Result<LogEntry, NodeError> {
8618        if let Some(entry) = self
8619            .log_store
8620            .read(stop.index())
8621            .map_err(|error| NodeError::Storage(error.to_string()))?
8622            .filter(|entry| entry.hash == stop.hash())
8623        {
8624            return Ok(entry);
8625        }
8626        if let Some(entry) = self
8627            .config
8628            .predecessor_stop_entry
8629            .as_ref()
8630            .filter(|entry| entry.index == stop.index() && entry.hash == stop.hash())
8631        {
8632            validate_entry_envelope(&self.config, entry, entry.index, entry.prev_hash)?;
8633            return Ok(entry.clone());
8634        }
8635        let proof = self
8636            .consensus
8637            .inspect_decision_proof_at(stop.index())
8638            .map_err(|error| self.map_consensus_error(error))?
8639            .ok_or_else(|| NodeError::Unavailable("durable Stop proof is unavailable".into()))?;
8640        let value = proof
8641            .proposal()
8642            .value
8643            .as_ref()
8644            .filter(|value| value.entry_hash == stop.hash())
8645            .ok_or_else(|| {
8646                self.latch(NodeError::Reconciliation(
8647                    "Stop proof differs from compacted anchor".into(),
8648                ))
8649            })?;
8650        match self
8651            .consensus
8652            .inspect_decision_at(stop.index(), value.prev_hash)
8653            .map_err(|error| self.map_consensus_error(error))?
8654        {
8655            DecisionInspection::Committed(entry) if entry.hash == stop.hash() => Ok(entry),
8656            DecisionInspection::Unavailable => Err(NodeError::Unavailable(
8657                "durable Stop command is unavailable".into(),
8658            )),
8659            DecisionInspection::Committed(_)
8660            | DecisionInspection::Empty
8661            | DecisionInspection::Pending => Err(self.latch(NodeError::Reconciliation(
8662                "Stop decision differs from compacted anchor".into(),
8663            ))),
8664        }
8665    }
8666
8667    pub fn log_root(&self) -> Result<LogAnchor, NodeError> {
8668        let _commit = self.lock_commit()?;
8669        self.log_root_unlocked()
8670    }
8671
8672    fn log_root_unlocked(&self) -> Result<LogAnchor, NodeError> {
8673        let state = self
8674            .log_store
8675            .logical_state()
8676            .map_err(|error| NodeError::Storage(error.to_string()))?;
8677        Ok(state.tip.map_or_else(
8678            || {
8679                state
8680                    .anchor
8681                    .map_or(LogAnchor::new(0, LogHash::ZERO), |anchor| {
8682                        *anchor.compacted()
8683                    })
8684            },
8685            |entry| LogAnchor::new(entry.index(), entry.hash()),
8686        ))
8687    }
8688
8689    pub fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
8690        fetch_runtime_log(self, request)
8691    }
8692
8693    #[cfg(feature = "sql")]
8694    pub fn create_recovery_snapshot(&self) -> Result<RecoverySnapshot, NodeError> {
8695        let _commit = self.lock_commit()?;
8696        self.ensure_ready()?;
8697        self.ensure_materialized_tip()?;
8698        self.lock_sqlite()?
8699            .create_recovery_snapshot(self.config.recovery_generation)
8700            .map_err(|error| self.map_sqlite_error(error))
8701    }
8702
8703    pub async fn checkpoint_compact(
8704        &self,
8705        coordinator: &CheckpointCoordinator,
8706    ) -> Result<RecoveryAnchor, DurabilityError> {
8707        coordinator.checkpoint_compact(self).await
8708    }
8709
8710    #[cfg_attr(not(any(feature = "sql", feature = "kv")), allow(unused_variables))]
8711    pub(crate) fn compact_embedded_log_before(
8712        &self,
8713        anchor_index: LogIndex,
8714    ) -> Result<(), NodeError> {
8715        let materializer = self.lock_materializer()?;
8716        match &*materializer {
8717            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
8718            Materializer::Unavailable => unreachable!("no execution profiles are compiled in"),
8719            #[cfg(feature = "sql")]
8720            Materializer::Sql(sql) => sql
8721                .compact_embedded_log_before(anchor_index)
8722                .map_err(|error| self.map_sqlite_error(error)),
8723            #[cfg(feature = "kv")]
8724            Materializer::Kv(kv) => kv
8725                .compact_embedded_log_before(anchor_index)
8726                .map_err(|error| NodeError::Storage(error.to_string())),
8727            #[cfg(feature = "graph")]
8728            Materializer::Graph(_) => Ok(()),
8729        }
8730    }
8731
8732    #[cfg(feature = "sql")]
8733    pub fn verify_snapshot_publication(
8734        &self,
8735        snapshot: &RecoverySnapshot,
8736        publication: &SnapshotRecord,
8737    ) -> Result<VerifiedSnapshotPublication, NodeError> {
8738        let anchor = snapshot.anchor();
8739        let manifest = publication.manifest();
8740        let publication_digest = LogHash::from_hex(publication.sha256()).ok_or_else(|| {
8741            NodeError::Reconciliation("published snapshot digest is invalid".into())
8742        })?;
8743        if anchor.cluster_id() != self.config.cluster_id
8744            || anchor.epoch() != self.config.epoch
8745            || anchor.config_id() != self.config.config_id()
8746            || anchor.recovery_generation() != self.config.recovery_generation
8747            || manifest.cluster_id() != anchor.cluster_id()
8748            || manifest.epoch() != anchor.epoch()
8749            || manifest.config_id() != anchor.config_id()
8750            || manifest.configuration_state() != anchor.configuration_state()
8751            || manifest.index() != anchor.compacted().index()
8752            || manifest.applied_hash() != anchor.compacted().hash()
8753            || manifest.snapshot_id() != anchor.snapshot().snapshot_id()
8754            || manifest.executor_fingerprint() != anchor.snapshot().executor_fingerprint()
8755            || publication_digest != anchor.snapshot().digest()
8756            || publication.size_bytes() != anchor.snapshot().size_bytes()
8757            || LogHash::digest(&[snapshot.db_bytes()]) != anchor.snapshot().digest()
8758            || snapshot.db_bytes().len() as u64 != anchor.snapshot().size_bytes()
8759        {
8760            return Err(NodeError::Reconciliation(
8761                "published snapshot does not match the runtime recovery anchor".into(),
8762            ));
8763        }
8764        Ok(VerifiedSnapshotPublication {
8765            anchor: anchor.clone(),
8766        })
8767    }
8768
8769    #[cfg(feature = "sql")]
8770    pub fn compact_log(&self, publication: &VerifiedSnapshotPublication) -> Result<(), NodeError> {
8771        let _commit = self.lock_commit()?;
8772        self.ensure_ready()?;
8773        let applied_index = self.applied_index()?;
8774        let applied_hash = self.applied_hash()?;
8775        let anchor = &publication.anchor;
8776        if anchor.cluster_id() != self.config.cluster_id
8777            || anchor.epoch() != self.config.epoch
8778            || anchor.config_id() != self.config.config_id()
8779            || anchor.recovery_generation() != self.config.recovery_generation
8780            || anchor.compacted().index() != applied_index
8781            || anchor.compacted().hash() != applied_hash
8782        {
8783            return Err(NodeError::Reconciliation(
8784                "verified snapshot anchor does not match the current applied entry".into(),
8785            ));
8786        }
8787        self.log_store
8788            .compact_prefix(anchor)
8789            .map_err(|error| NodeError::Storage(error.to_string()))?;
8790        self.compact_embedded_log_before(anchor.compacted().index())
8791    }
8792
8793    pub fn is_ready(&self) -> bool {
8794        self.ready.load(Ordering::Acquire) && !self.fatal.load(Ordering::Acquire)
8795    }
8796
8797    pub fn is_fatal(&self) -> bool {
8798        self.fatal.load(Ordering::Acquire)
8799    }
8800
8801    pub fn fatal_reason(&self) -> Option<String> {
8802        self.fatal_reason
8803            .lock()
8804            .ok()
8805            .and_then(|reason| reason.clone())
8806    }
8807
8808    #[cfg(feature = "sql")]
8809    fn read_local(
8810        &self,
8811        key: &str,
8812        required_index: Option<LogIndex>,
8813    ) -> Result<ReadResponse, NodeError> {
8814        self.ensure_ready()?;
8815        self.ensure_writes_active()?;
8816        let sqlite = self.lock_sqlite()?;
8817        let (applied_index, hash) = sqlite
8818            .applied_tip_value()
8819            .map_err(|error| self.map_sqlite_error(error))?;
8820        if required_index.is_some_and(|required| applied_index < required) {
8821            return Err(NodeError::Unavailable(format!(
8822                "local applied index {applied_index} has not reached {}",
8823                required_index.expect("checked above")
8824            )));
8825        }
8826        let value = sqlite
8827            .get_value(key)
8828            .map_err(|error| self.map_sqlite_error(error))?;
8829        Ok(ReadResponse {
8830            value,
8831            applied_index,
8832            hash,
8833        })
8834    }
8835
8836    #[cfg(feature = "graph")]
8837    fn get_graph_document_local(
8838        &self,
8839        id: &str,
8840        required_index: Option<LogIndex>,
8841    ) -> Result<GraphReadResponse, NodeError> {
8842        self.ensure_ready()?;
8843        self.ensure_writes_active()?;
8844        let graph = self.graph_materializer()?;
8845        let (value, applied_index, hash) = graph
8846            .get_document_with_tip(id)
8847            .map_err(|error| self.map_graph_read_error(error))?;
8848        if required_index.is_some_and(|required| applied_index < required) {
8849            return Err(NodeError::Unavailable(format!(
8850                "local applied index {applied_index} has not reached {}",
8851                required_index.expect("checked above")
8852            )));
8853        }
8854        Ok(GraphReadResponse {
8855            value,
8856            applied_index,
8857            hash,
8858        })
8859    }
8860
8861    #[cfg(feature = "graph")]
8862    fn query_graph_local(
8863        &self,
8864        statement: &str,
8865        parameters: &BTreeMap<String, GraphParameterValue>,
8866        required_index: Option<LogIndex>,
8867        max_rows: u32,
8868    ) -> Result<GraphQueryResult, NodeError> {
8869        self.ensure_ready()?;
8870        self.ensure_writes_active()?;
8871        let graph = self.graph_materializer()?;
8872        let result = graph
8873            .query_read_only(
8874                statement,
8875                parameters,
8876                usize::try_from(max_rows).expect("u32 fits usize"),
8877                MAX_GRAPH_RESULT_BYTES,
8878                GRAPH_QUERY_TIMEOUT_MS,
8879            )
8880            .map_err(|error| self.map_graph_read_error(error))?;
8881        if required_index.is_some_and(|required| result.applied_index < required) {
8882            return Err(NodeError::Unavailable(format!(
8883                "local applied index {} has not reached {}",
8884                result.applied_index,
8885                required_index.expect("checked above")
8886            )));
8887        }
8888        Ok(result)
8889    }
8890
8891    #[cfg(feature = "kv")]
8892    fn get_kv_local(
8893        &self,
8894        key: &[u8],
8895        required_index: Option<LogIndex>,
8896    ) -> Result<KvReadResponse, NodeError> {
8897        self.ensure_ready()?;
8898        self.ensure_writes_active()?;
8899        let kv = self.kv_materializer()?;
8900        let result = kv
8901            .get_with_tip(key)
8902            .map_err(|error| self.map_kv_read_error(error))?;
8903        let (value, tip) = result.into_parts();
8904        let applied_index = tip.applied_index();
8905        if required_index.is_some_and(|required| applied_index < required) {
8906            return Err(NodeError::Unavailable(format!(
8907                "local applied index {applied_index} has not reached {}",
8908                required_index.expect("checked above")
8909            )));
8910        }
8911        Ok(KvReadResponse {
8912            value,
8913            applied_index,
8914            hash: tip.applied_hash(),
8915        })
8916    }
8917
8918    #[cfg(feature = "kv")]
8919    fn scan_kv_range_local(
8920        &self,
8921        start: &[u8],
8922        end: Option<&[u8]>,
8923        limit: usize,
8924        cursor: Option<&[u8]>,
8925        required_index: Option<LogIndex>,
8926    ) -> Result<KvScanResult, NodeError> {
8927        self.ensure_ready()?;
8928        self.ensure_writes_active()?;
8929        let kv = self.kv_materializer()?;
8930        let result = kv
8931            .scan_range(start, end, limit, cursor)
8932            .map_err(|error| self.map_kv_read_error(error))?;
8933        validate_kv_scan_required_index(&result, required_index)?;
8934        Ok(result)
8935    }
8936
8937    #[cfg(feature = "kv")]
8938    fn scan_kv_prefix_local(
8939        &self,
8940        prefix: &[u8],
8941        limit: usize,
8942        cursor: Option<&[u8]>,
8943        required_index: Option<LogIndex>,
8944    ) -> Result<KvScanResult, NodeError> {
8945        self.ensure_ready()?;
8946        self.ensure_writes_active()?;
8947        let kv = self.kv_materializer()?;
8948        let result = kv
8949            .scan_prefix(prefix, limit, cursor)
8950            .map_err(|error| self.map_kv_read_error(error))?;
8951        validate_kv_scan_required_index(&result, required_index)?;
8952        Ok(result)
8953    }
8954
8955    #[cfg(feature = "sql")]
8956    fn query_sql_local(
8957        &self,
8958        statement: &SqlStatement,
8959        required_index: Option<LogIndex>,
8960        max_rows: u32,
8961    ) -> Result<SqlQueryResponse, NodeError> {
8962        self.ensure_ready()?;
8963        self.ensure_writes_active()?;
8964        let sqlite = self.lock_sqlite()?;
8965        let (applied_index, hash) = sqlite
8966            .applied_tip_value()
8967            .map_err(|error| self.map_sqlite_error(error))?;
8968        if required_index.is_some_and(|required| applied_index < required) {
8969            return Err(NodeError::Unavailable(format!(
8970                "local applied index {applied_index} has not reached {}",
8971                required_index.expect("checked above")
8972            )));
8973        }
8974        let SqlQueryResult { columns, rows } = sqlite
8975            .query_sql(
8976                statement,
8977                usize::try_from(max_rows).expect("u32 fits usize"),
8978                MAX_SQL_RESULT_BYTES,
8979            )
8980            .map_err(|error| match error {
8981                rhiza_sql::Error::ResourceExhausted(message) => {
8982                    NodeError::ResourceExhausted(message)
8983                }
8984                other => NodeError::InvalidSqlStatement {
8985                    statement_index: 0,
8986                    message: other.to_string(),
8987                },
8988            })?;
8989        Ok(SqlQueryResponse {
8990            columns,
8991            rows,
8992            applied_index,
8993            hash,
8994        })
8995    }
8996
8997    fn establish_read_barrier(&self) -> Result<LogAnchor, NodeError> {
8998        let participant = self.read_barriers.join().map_err(|error| match error {
8999            NodeError::Invariant(_) => self.latch(error),
9000            other => other,
9001        })?;
9002        let Some(mut publication) = participant.publication() else {
9003            return participant.wait(&self.operation_cancelled);
9004        };
9005
9006        let result = (|| {
9007            publication.wait_turn(&self.operation_cancelled)?;
9008            // The public read path must not own this mutex before entering the
9009            // coalescer. The generation cutoff happens only after the round
9010            // leader owns commit, immediately before consensus begins.
9011            let _commit = self.lock_commit()?;
9012            publication.start(&self.operation_cancelled)?;
9013            self.ensure_ready()?;
9014            self.commit_read_barrier_locked()
9015        })();
9016        publication.publish(result.clone());
9017        result
9018    }
9019
9020    #[cfg(any(feature = "graph", feature = "kv"))]
9021    fn validate_read_barrier_before_snapshot(&self, anchor: LogAnchor) -> Result<(), NodeError> {
9022        {
9023            let _commit = self.lock_commit()?;
9024            self.ensure_ready()?;
9025            self.ensure_writes_active()?;
9026            self.validate_read_barrier_qlog_descendant_locked(anchor)?;
9027        }
9028        Ok(())
9029    }
9030
9031    #[cfg(any(feature = "graph", feature = "kv"))]
9032    fn validate_read_barrier_snapshot(
9033        &self,
9034        anchor: LogAnchor,
9035        observed: LogAnchor,
9036    ) -> Result<(), NodeError> {
9037        if observed.index() < anchor.index() {
9038            return Err(NodeError::Unavailable(format!(
9039                "read snapshot tip {} precedes read barrier {}",
9040                observed.index(),
9041                anchor.index()
9042            )));
9043        }
9044        if observed.index() == anchor.index() && observed.hash() != anchor.hash() {
9045            return Err(self.latch(NodeError::Invariant(
9046                "read snapshot tip hash differs from the read barrier anchor".into(),
9047            )));
9048        }
9049        Ok(())
9050    }
9051
9052    #[cfg(feature = "sql")]
9053    fn validate_read_barrier_descendant_locked(&self, anchor: LogAnchor) -> Result<(), NodeError> {
9054        let (applied_index, applied_hash) = self.ensure_materialized_tip()?;
9055        self.validate_read_barrier_descendant_from_tip(
9056            anchor,
9057            LogAnchor::new(applied_index, applied_hash),
9058            "materialized",
9059        )
9060    }
9061
9062    #[cfg(any(feature = "graph", feature = "kv"))]
9063    fn validate_read_barrier_qlog_descendant_locked(
9064        &self,
9065        anchor: LogAnchor,
9066    ) -> Result<(), NodeError> {
9067        let (qlog_index, qlog_hash) = self.durable_tip()?;
9068        self.validate_read_barrier_descendant_from_tip(
9069            anchor,
9070            LogAnchor::new(qlog_index, qlog_hash),
9071            "qlog",
9072        )
9073    }
9074
9075    fn validate_read_barrier_descendant_from_tip(
9076        &self,
9077        anchor: LogAnchor,
9078        tip: LogAnchor,
9079        tip_kind: &str,
9080    ) -> Result<(), NodeError> {
9081        if tip.index() < anchor.index() {
9082            return Err(self.latch(NodeError::Invariant(format!(
9083                "{tip_kind} tip {} precedes read barrier {}",
9084                tip.index(),
9085                anchor.index()
9086            ))));
9087        }
9088        if tip.index() == anchor.index() {
9089            if tip.hash() != anchor.hash() {
9090                return Err(self.latch(NodeError::Invariant(format!(
9091                    "{tip_kind} tip hash differs from the read barrier anchor"
9092                ))));
9093            }
9094            return Ok(());
9095        }
9096        if anchor.index() == 0 {
9097            if anchor.hash() == LogHash::ZERO {
9098                return Ok(());
9099            }
9100            return Err(self.latch(NodeError::Invariant(
9101                "genesis read barrier anchor has a non-zero hash".into(),
9102            )));
9103        }
9104
9105        let logical = self
9106            .log_store
9107            .logical_state()
9108            .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9109        if let Some(compacted) = logical.anchor.as_ref().map(RecoveryAnchor::compacted) {
9110            if compacted.index() > anchor.index() {
9111                return Ok(());
9112            }
9113            if compacted.index() == anchor.index() {
9114                if compacted.hash() == anchor.hash() {
9115                    return Ok(());
9116                }
9117                return Err(self.latch(NodeError::Invariant(
9118                    "compacted qlog hash differs from the read barrier anchor".into(),
9119                )));
9120            }
9121        }
9122        let retained = self
9123            .log_store
9124            .read(anchor.index())
9125            .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?
9126            .ok_or_else(|| {
9127                self.latch(NodeError::Invariant(
9128                    "read barrier anchor is neither retained nor compacted".into(),
9129                ))
9130            })?;
9131        if retained.hash != anchor.hash() {
9132            return Err(self.latch(NodeError::Invariant(
9133                "retained qlog hash differs from the read barrier anchor".into(),
9134            )));
9135        }
9136        Ok(())
9137    }
9138
9139    fn commit_read_barrier_locked(&self) -> Result<LogAnchor, NodeError> {
9140        self.ensure_writes_active()?;
9141        let context_read_fence = self.consensus.supports_context_read_fence();
9142        loop {
9143            self.ensure_ready()?;
9144            let (last_index, last_hash) = self.ensure_materialized_tip()?;
9145            let slot = last_index.checked_add(1).ok_or_else(|| {
9146                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
9147            })?;
9148            let inspection = if context_read_fence {
9149                match self
9150                    .consensus
9151                    .inspect_context_read_fence_at(slot, last_hash)
9152                    .map_err(|error| self.map_consensus_error(error))?
9153                {
9154                    CertifiedDecisionInspection::Committed(certified) => {
9155                        DecisionInspection::Committed(certified.entry)
9156                    }
9157                    CertifiedDecisionInspection::Empty => DecisionInspection::Empty,
9158                    CertifiedDecisionInspection::Pending => DecisionInspection::Pending,
9159                    CertifiedDecisionInspection::Unavailable => DecisionInspection::Unavailable,
9160                }
9161            } else {
9162                self.consensus
9163                    .inspect_decision_at(slot, last_hash)
9164                    .map_err(|error| self.map_consensus_error(error))?
9165            };
9166            match inspection {
9167                DecisionInspection::Committed(entry) => {
9168                    self.persist_entry(&entry, slot, last_hash)?;
9169                }
9170                DecisionInspection::Empty if context_read_fence => {
9171                    // Configuration may have been sealed while the read-only
9172                    // quorum observation was in flight. Never return an anchor
9173                    // from a configuration that is no longer write-active.
9174                    self.ensure_writes_active()?;
9175                    return Ok(LogAnchor::new(last_index, last_hash));
9176                }
9177                DecisionInspection::Pending => {
9178                    let entry = self
9179                        .consensus
9180                        .propose_at_cancellable(
9181                            slot,
9182                            last_hash,
9183                            Command::new(CommandKind::ReadBarrier, Vec::new()),
9184                            &self.operation_cancelled,
9185                        )
9186                        .map_err(|error| self.map_consensus_error(error))?;
9187                    self.persist_entry(&entry, slot, last_hash)?;
9188                    // Pending may conceal a historical phase-2 Noop whose
9189                    // asynchronous proof was never installed. It cannot fence
9190                    // this read, so inspect the following slot before returning.
9191                }
9192                DecisionInspection::Empty => {
9193                    let entry = self
9194                        .consensus
9195                        .propose_at_cancellable(
9196                            slot,
9197                            last_hash,
9198                            Command::new(CommandKind::ReadBarrier, Vec::new()),
9199                            &self.operation_cancelled,
9200                        )
9201                        .map_err(|error| self.map_consensus_error(error))?;
9202                    let is_barrier =
9203                        entry.entry_type == EntryType::Noop && entry.payload.is_empty();
9204                    self.persist_entry(&entry, slot, last_hash)?;
9205                    if is_barrier {
9206                        return Ok(LogAnchor::new(entry.index, entry.hash));
9207                    }
9208                }
9209                DecisionInspection::Unavailable => {
9210                    return Err(NodeError::Unavailable(
9211                        "decision inspection did not reach quorum".into(),
9212                    ));
9213                }
9214            }
9215        }
9216    }
9217
9218    #[cfg(feature = "sql")]
9219    fn check_request(
9220        &self,
9221        request_id: &str,
9222        payload: &[u8],
9223    ) -> Result<Option<RequestOutcome>, NodeError> {
9224        let sqlite = self.lock_sqlite()?;
9225        sqlite
9226            .check_request(request_id, payload)
9227            .map_err(|error| self.map_sqlite_error(error))
9228    }
9229
9230    #[cfg(feature = "graph")]
9231    #[cfg_attr(
9232        all(not(feature = "sql"), not(feature = "kv")),
9233        allow(irrefutable_let_patterns)
9234    )]
9235    fn check_graph_request(
9236        &self,
9237        request_id: &str,
9238        payload: &[u8],
9239    ) -> Result<Option<GraphRequestRecord>, NodeError> {
9240        let materializer = self.lock_materializer()?;
9241        let Materializer::Graph(graph) = &*materializer else {
9242            return Err(NodeError::ExecutionProfileMismatch {
9243                expected: ExecutionProfile::Graph,
9244                actual: materializer.profile(),
9245            });
9246        };
9247        graph
9248            .check_request(request_id, payload)
9249            .map_err(|error| NodeError::InvalidRequest(error.to_string()))
9250    }
9251
9252    #[cfg(feature = "kv")]
9253    #[cfg_attr(
9254        all(not(feature = "sql"), not(feature = "graph")),
9255        allow(irrefutable_let_patterns)
9256    )]
9257    fn check_kv_request(
9258        &self,
9259        request_id: &str,
9260        payload: &[u8],
9261    ) -> Result<Option<KvRequestRecord>, NodeError> {
9262        let materializer = self.lock_materializer()?;
9263        let Materializer::Kv(kv) = &*materializer else {
9264            return Err(NodeError::ExecutionProfileMismatch {
9265                expected: ExecutionProfile::Kv,
9266                actual: materializer.profile(),
9267            });
9268        };
9269        kv.check_request(request_id, payload)
9270            .map_err(|error| NodeError::InvalidRequest(error.to_string()))
9271    }
9272
9273    fn ensure_materialized_tip(&self) -> Result<(LogIndex, LogHash), NodeError> {
9274        #[cfg(test)]
9275        self.materialized_tip_checks.fetch_add(1, Ordering::Relaxed);
9276        let (last_index, last_hash) = self.durable_tip()?;
9277        let materializer = self.lock_materializer()?;
9278        let applied_tip = materializer
9279            .applied_tip()
9280            .map_err(|error| self.latch(NodeError::Storage(error)))?;
9281        let applied_index = applied_tip.index();
9282        let applied_hash = applied_tip.hash();
9283        if (applied_index, applied_hash) != (last_index, last_hash) {
9284            return Err(self.latch(NodeError::Invariant(format!(
9285                "qlog tip {last_index}/{} differs from {} materializer tip {applied_index}/{}",
9286                last_hash.to_hex(),
9287                materializer.profile(),
9288                applied_hash.to_hex()
9289            ))));
9290        }
9291        Ok((last_index, last_hash))
9292    }
9293
9294    fn durable_tip(&self) -> Result<(LogIndex, LogHash), NodeError> {
9295        static_log_tip(&self.log_store).map_err(|error| self.latch(error))
9296    }
9297
9298    fn persist_entry(
9299        &self,
9300        entry: &LogEntry,
9301        expected_index: LogIndex,
9302        expected_prev_hash: LogHash,
9303    ) -> Result<Option<SqlCommandResult>, NodeError> {
9304        let configuration_state = self.configuration_state()?;
9305        validate_runtime_entry(
9306            &self.config,
9307            &configuration_state,
9308            entry,
9309            expected_index,
9310            expected_prev_hash,
9311        )
9312        .map_err(|error| self.latch(error))?;
9313        if matches!(
9314            self.config.execution_profile,
9315            ExecutionProfile::Sqlite | ExecutionProfile::Kv
9316        ) {
9317            // SQL/KV embed the complete entry in their durable materializer state. The file qlog
9318            // remains a buffered serving mirror and is rehydrated on startup.
9319            self.log_store
9320                .append_batch_buffered(std::slice::from_ref(entry))
9321                .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9322        } else {
9323            self.log_store
9324                .append(entry)
9325                .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9326        }
9327        self.lock_materializer()?
9328            .apply_entry(entry)
9329            .map_err(|error| self.latch(NodeError::Invariant(error)))
9330    }
9331
9332    #[cfg(feature = "sql")]
9333    fn persist_sql_entry_profiled<P: SqlWritePhaseProfile>(
9334        &self,
9335        entry: &LogEntry,
9336        expected_index: LogIndex,
9337        expected_prev_hash: LogHash,
9338        profile: &mut P,
9339    ) -> Result<Option<SqlCommandResult>, NodeError> {
9340        let configuration_state = self.configuration_state()?;
9341        validate_runtime_entry(
9342            &self.config,
9343            &configuration_state,
9344            entry,
9345            expected_index,
9346            expected_prev_hash,
9347        )
9348        .map_err(|error| self.latch(error))?;
9349
9350        let qlog_mark = profile.mark();
9351        let append_result = self
9352            .log_store
9353            .append_batch_buffered(std::slice::from_ref(entry))
9354            .map_err(|error| self.latch(NodeError::Storage(error.to_string())));
9355        profile.add_local_qlog_mirror_append(qlog_mark);
9356        append_result?;
9357
9358        let materializer_mark = profile.mark();
9359        let apply_result = self
9360            .lock_materializer()?
9361            .apply_entry(entry)
9362            .map_err(|error| self.latch(NodeError::Invariant(error)));
9363        profile.add_sql_materializer_apply(materializer_mark);
9364        apply_result
9365    }
9366
9367    fn require_execution_profile(&self, expected: ExecutionProfile) -> Result<(), NodeError> {
9368        if self.config.execution_profile == expected {
9369            Ok(())
9370        } else {
9371            Err(NodeError::ExecutionProfileMismatch {
9372                expected,
9373                actual: self.config.execution_profile,
9374            })
9375        }
9376    }
9377
9378    fn ensure_ready(&self) -> Result<(), NodeError> {
9379        if self.fatal.load(Ordering::Acquire) {
9380            return Err(NodeError::Fatal(
9381                self.fatal_reason()
9382                    .unwrap_or_else(|| "fatal state is latched".into()),
9383            ));
9384        }
9385        if !self.ready.load(Ordering::Acquire) {
9386            return Err(NodeError::Unavailable("runtime is not ready".into()));
9387        }
9388        Ok(())
9389    }
9390
9391    fn ensure_writes_active(&self) -> Result<(), NodeError> {
9392        let state = self.configuration_state()?;
9393        if state.is_active() {
9394            Ok(())
9395        } else {
9396            Err(NodeError::ConfigurationTransition {
9397                state: Box::new(state),
9398            })
9399        }
9400    }
9401
9402    fn lock_commit(&self) -> Result<MutexGuard<'_, ()>, NodeError> {
9403        self.commit
9404            .lock()
9405            .map_err(|_| self.latch(NodeError::Invariant("commit mutex is poisoned".into())))
9406    }
9407
9408    fn lock_materializer(&self) -> Result<MutexGuard<'_, Materializer>, NodeError> {
9409        self.materializer.lock().map_err(|_| {
9410            self.latch(NodeError::Invariant(
9411                "materializer mutex is poisoned".into(),
9412            ))
9413        })
9414    }
9415
9416    #[cfg(feature = "graph")]
9417    #[cfg_attr(
9418        all(not(feature = "sql"), not(feature = "kv")),
9419        allow(irrefutable_let_patterns)
9420    )]
9421    fn graph_materializer(&self) -> Result<Arc<LadybugStateMachine>, NodeError> {
9422        let materializer = self.lock_materializer()?;
9423        let Materializer::Graph(graph) = &*materializer else {
9424            return Err(NodeError::ExecutionProfileMismatch {
9425                expected: ExecutionProfile::Graph,
9426                actual: materializer.profile(),
9427            });
9428        };
9429        Ok(Arc::clone(graph))
9430    }
9431
9432    #[cfg(feature = "kv")]
9433    #[cfg_attr(
9434        all(not(feature = "sql"), not(feature = "graph")),
9435        allow(irrefutable_let_patterns)
9436    )]
9437    fn kv_materializer(&self) -> Result<Arc<RedbStateMachine>, NodeError> {
9438        let materializer = self.lock_materializer()?;
9439        let Materializer::Kv(kv) = &*materializer else {
9440            return Err(NodeError::ExecutionProfileMismatch {
9441                expected: ExecutionProfile::Kv,
9442                actual: materializer.profile(),
9443            });
9444        };
9445        Ok(Arc::clone(kv))
9446    }
9447
9448    #[cfg(feature = "sql")]
9449    fn lock_sqlite(&self) -> Result<SqlMaterializerGuard<'_>, NodeError> {
9450        let guard = self.lock_materializer()?;
9451        if !matches!(&*guard, Materializer::Sql(_)) {
9452            return Err(NodeError::ExecutionProfileMismatch {
9453                expected: ExecutionProfile::Sqlite,
9454                actual: guard.profile(),
9455            });
9456        }
9457        Ok(SqlMaterializerGuard(guard))
9458    }
9459
9460    #[cfg(feature = "sql")]
9461    fn map_sqlite_error(&self, error: rhiza_sql::Error) -> NodeError {
9462        match error {
9463            rhiza_sql::Error::RequestConflict(conflict) => NodeError::RequestConflict(conflict),
9464            rhiza_sql::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9465            rhiza_sql::Error::InvalidCommand(message)
9466            | rhiza_sql::Error::IdentityMismatch(message)
9467            | rhiza_sql::Error::InvalidEntry(message)
9468            | rhiza_sql::Error::InvalidSnapshot(message) => {
9469                self.latch(NodeError::Invariant(message))
9470            }
9471            other => self.latch(NodeError::Storage(other.to_string())),
9472        }
9473    }
9474
9475    #[cfg(feature = "graph")]
9476    fn map_graph_read_error(&self, error: rhiza_graph::Error) -> NodeError {
9477        match error {
9478            rhiza_graph::Error::InvalidCommand(_) => NodeError::InvalidRequest(error.to_string()),
9479            rhiza_graph::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9480            rhiza_graph::Error::Ladybug(_) | rhiza_graph::Error::Io(_) => {
9481                self.latch(NodeError::Storage(error.to_string()))
9482            }
9483            rhiza_graph::Error::Closed
9484            | rhiza_graph::Error::Codec(_)
9485            | rhiza_graph::Error::InvalidEntry(_)
9486            | rhiza_graph::Error::IdentityMismatch(_)
9487            | rhiza_graph::Error::RequestConflict { .. }
9488            | rhiza_graph::Error::InvalidSnapshot(_) => {
9489                self.latch(NodeError::Invariant(error.to_string()))
9490            }
9491        }
9492    }
9493
9494    #[cfg(feature = "kv")]
9495    fn map_kv_read_error(&self, error: rhiza_kv::Error) -> NodeError {
9496        match error {
9497            rhiza_kv::Error::InvalidCommand(_) | rhiza_kv::Error::InvalidQuery(_) => {
9498                NodeError::InvalidRequest(error.to_string())
9499            }
9500            rhiza_kv::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9501            rhiza_kv::Error::Database(_) | rhiza_kv::Error::Io(_) => {
9502                self.latch(NodeError::Storage(error.to_string()))
9503            }
9504            rhiza_kv::Error::Codec(_)
9505            | rhiza_kv::Error::InvalidEntry(_)
9506            | rhiza_kv::Error::PartialInitialization
9507            | rhiza_kv::Error::RequestConflict { .. }
9508            | rhiza_kv::Error::InvalidSnapshot(_) => {
9509                self.latch(NodeError::Invariant(error.to_string()))
9510            }
9511        }
9512    }
9513
9514    fn map_consensus_error(&self, error: rhiza_quepaxa::Error) -> NodeError {
9515        match error {
9516            rhiza_quepaxa::Error::NoQuorum
9517            | rhiza_quepaxa::Error::ProposeFailed
9518            | rhiza_quepaxa::Error::CommandUnavailable
9519            | rhiza_quepaxa::Error::Cancelled
9520            | rhiza_quepaxa::Error::Io(_) => NodeError::Unavailable(error.to_string()),
9521            rhiza_quepaxa::Error::ConflictingCertificates
9522            | rhiza_quepaxa::Error::ChainConflict { .. } => {
9523                self.latch(NodeError::Reconciliation(error.to_string()))
9524            }
9525            other => self.latch(NodeError::Invariant(other.to_string())),
9526        }
9527    }
9528
9529    fn latch(&self, error: NodeError) -> NodeError {
9530        self.ready.store(false, Ordering::Release);
9531        if !self.fatal.swap(true, Ordering::AcqRel) {
9532            eprintln!(
9533                "node runtime entered fatal state: {}",
9534                escaped_error_detail(&error)
9535            );
9536            if let Ok(mut reason) = self.fatal_reason.lock() {
9537                *reason = Some(error.to_string());
9538            }
9539        }
9540        error
9541    }
9542}
9543
9544pub fn rehydrate_recorder_after_checkpoint(
9545    runtime: &NodeRuntime,
9546    recorder: &RecorderFileStore,
9547    checkpoint_index: LogIndex,
9548    startup: &StartupIoContext,
9549) -> Result<(), NodeError> {
9550    startup.check("recorder rehydration anchor validation")?;
9551    if let Some(anchor) = runtime
9552        .log_store()
9553        .logical_state()
9554        .map_err(|error| NodeError::Storage(error.to_string()))?
9555        .anchor
9556    {
9557        if checkpoint_index < anchor.compacted().index() {
9558            return Err(NodeError::SnapshotRequired(Box::new(anchor)));
9559        }
9560    }
9561    let applied_index = runtime.applied_index()?;
9562    if checkpoint_index > applied_index {
9563        return Err(NodeError::Reconciliation(format!(
9564            "checkpoint tip {checkpoint_index} is ahead of local applied index {applied_index}"
9565        )));
9566    }
9567
9568    for index in checkpoint_index.saturating_add(1)..=applied_index {
9569        startup.check("recorder rehydration qlog read")?;
9570        let entry = runtime
9571            .log_store()
9572            .read(index)
9573            .map_err(|error| NodeError::Storage(error.to_string()))?
9574            .ok_or_else(|| {
9575                NodeError::Reconciliation(format!(
9576                    "qlog entry {index} is missing during recorder rehydration"
9577                ))
9578            })?;
9579        startup.check("recorder rehydration decision inspection")?;
9580        let certified = match runtime
9581            .consensus()
9582            .inspect_certified_decision_at(index, entry.prev_hash)
9583            .map_err(startup_consensus_error)?
9584        {
9585            CertifiedDecisionInspection::Committed(certified) => certified,
9586            CertifiedDecisionInspection::Empty => {
9587                return Err(NodeError::Reconciliation(format!(
9588                    "qlog entry {index} has no recorder decision certificate"
9589                )))
9590            }
9591            CertifiedDecisionInspection::Pending => {
9592                return Err(NodeError::Reconciliation(format!(
9593                    "qlog entry {index} has only a pending recorder decision"
9594                )))
9595            }
9596            CertifiedDecisionInspection::Unavailable => {
9597                return Err(NodeError::Unavailable(format!(
9598                    "recorder decision certificate is unavailable at qlog index {index}"
9599                )))
9600            }
9601        };
9602        startup.check("recorder rehydration decision inspection")?;
9603        if certified.entry != entry {
9604            return Err(NodeError::Reconciliation(format!(
9605                "recorder decision certificate differs from qlog entry {index}"
9606            )));
9607        }
9608        let command = StoredCommand::new(entry.entry_type, entry.payload.clone());
9609        let proof = certified.proof.clone();
9610        startup.persist("recorder rehydration persistence", || {
9611            recorder
9612                .store_command(command.hash(), command)
9613                .map_err(|error| {
9614                    NodeError::Reconciliation(format!(
9615                        "cannot restore recorder command at qlog index {index}: {error}"
9616                    ))
9617                })?;
9618            recorder
9619                .install_decision_proof_record(proof, runtime.consensus().membership())
9620                .map_err(|error| {
9621                    NodeError::Reconciliation(format!(
9622                        "cannot install recorder decision at qlog index {index}: {error}"
9623                    ))
9624                })
9625        })?;
9626    }
9627    Ok(())
9628}
9629
9630#[cfg(test)]
9631mod tests {
9632    use axum::http::HeaderValue;
9633
9634    use rhiza_core::{
9635        Command, CommandKind, ConfigurationState, EntryType, ErrorCategory, ErrorClassification,
9636        ExecutionProfile, LogAnchor, LogHash, RecoveryAnchor, SnapshotIdentity, StoredCommand,
9637    };
9638    #[cfg(feature = "graph")]
9639    use rhiza_graph::{GraphCommandV1, GraphValueV1};
9640    #[cfg(feature = "kv")]
9641    use rhiza_kv::KvCommandV1;
9642    use rhiza_log::LogStore as _;
9643    use rhiza_quepaxa::{
9644        AcceptedValue, Membership, Proposal, ProposalPriority, RecordRequest, RecordSummary,
9645        RecorderFileStore, RecorderRpc, ThreeNodeConsensus,
9646    };
9647    use std::sync::{
9648        atomic::{AtomicBool, AtomicUsize, Ordering},
9649        mpsc, Arc, Barrier, Condvar, Mutex,
9650    };
9651
9652    #[cfg(any(feature = "sql", feature = "graph", feature = "kv"))]
9653    use super::node_error_response;
9654    #[cfg(feature = "graph")]
9655    use super::with_graph_client_permit;
9656    use super::ReadBarrierRounds;
9657    use super::{
9658        client_authenticated, next_sync_flush_retry, run_read_operation, sql_query_http_response,
9659        valid_recorder_record, Duration, FileLogStore, HeaderMap, Instant, NodeError,
9660        ReadConsistency, SqlCommand, SqlQueryResponse, SqlStatement, SqlValue, SqlWriteProfiler,
9661        MAX_COMMAND_BYTES, MAX_SQL_RESPONSE_BYTES, PROTOCOL_VERSION, QWAL_V3_MAGIC,
9662        SYNC_FLUSH_RETRY_INITIAL, VERSION_HEADER,
9663    };
9664    use super::{ConfigError, NodeConfig, NodeRuntime, NodeService, StartupIoContext};
9665
9666    struct BlockingStartupInspection {
9667        recorder: RecorderFileStore,
9668        started: mpsc::SyncSender<()>,
9669        gate: Arc<(Mutex<bool>, Condvar)>,
9670    }
9671
9672    impl RecorderRpc for BlockingStartupInspection {
9673        fn recorder_id(&self) -> rhiza_quepaxa::Result<String> {
9674            self.recorder.recorder_id()
9675        }
9676
9677        fn inspect_record_summary(
9678            &self,
9679            slot: u64,
9680        ) -> rhiza_quepaxa::Result<Option<RecordSummary>> {
9681            self.started.send(()).unwrap();
9682            let (released, condition) = &*self.gate;
9683            let mut released = released.lock().unwrap();
9684            while !*released {
9685                released = condition.wait(released).unwrap();
9686            }
9687            self.recorder.inspect_record_summary(slot)
9688        }
9689
9690        fn fetch_command_for(
9691            &self,
9692            cluster_id: String,
9693            epoch: u64,
9694            config_id: u64,
9695            config_digest: LogHash,
9696            command_hash: LogHash,
9697        ) -> rhiza_quepaxa::Result<Option<StoredCommand>> {
9698            self.recorder.fetch_command_for(
9699                cluster_id,
9700                epoch,
9701                config_id,
9702                config_digest,
9703                command_hash,
9704            )
9705        }
9706    }
9707
9708    #[test]
9709    fn runtime_open_does_not_persist_a_decision_returned_after_cancellation() {
9710        let root = tempfile::tempdir().unwrap();
9711        let node_ids = ["n1", "n2", "n3"];
9712        let membership = Membership::new(node_ids).unwrap();
9713        let recorders = node_ids
9714            .iter()
9715            .map(|node_id| {
9716                (
9717                    (*node_id).to_owned(),
9718                    RecorderFileStore::new_with_membership(
9719                        root.path().join("recorders").join(node_id),
9720                        *node_id,
9721                        "rhiza:sql:startup-cancel-test",
9722                        1,
9723                        1,
9724                        membership.clone(),
9725                    )
9726                    .unwrap(),
9727                )
9728            })
9729            .collect::<Vec<_>>();
9730        let seed_consensus = ThreeNodeConsensus::from_recorders_with_ids(
9731            "rhiza:sql:startup-cancel-test",
9732            "n1",
9733            1,
9734            1,
9735            recorders
9736                .iter()
9737                .map(|(id, recorder)| {
9738                    (
9739                        id.clone(),
9740                        Box::new(recorder.clone()) as Box<dyn RecorderRpc>,
9741                    )
9742                })
9743                .collect(),
9744        )
9745        .unwrap();
9746        seed_consensus
9747            .propose_at(
9748                1,
9749                LogHash::ZERO,
9750                Command::new(CommandKind::ReadBarrier, Vec::new()),
9751            )
9752            .unwrap();
9753        assert!(seed_consensus.finish_pending_rpcs(Duration::from_secs(1)));
9754        drop(seed_consensus);
9755
9756        let (started, blocked) = mpsc::sync_channel(3);
9757        let gate = Arc::new((Mutex::new(false), Condvar::new()));
9758        let consensus = Arc::new(
9759            ThreeNodeConsensus::from_recorders_with_ids(
9760                "rhiza:sql:startup-cancel-test",
9761                "n1",
9762                1,
9763                1,
9764                recorders
9765                    .into_iter()
9766                    .map(|(id, recorder)| {
9767                        (
9768                            id,
9769                            Box::new(BlockingStartupInspection {
9770                                recorder,
9771                                started: started.clone(),
9772                                gate: Arc::clone(&gate),
9773                            }) as Box<dyn RecorderRpc>,
9774                        )
9775                    })
9776                    .collect(),
9777            )
9778            .unwrap(),
9779        );
9780        let config = NodeConfig::new_embedded(
9781            "startup-cancel-test",
9782            "n1",
9783            root.path().join("node"),
9784            1,
9785            1,
9786            node_ids,
9787        )
9788        .unwrap();
9789        let startup = StartupIoContext::new();
9790        let attempt_startup = startup.clone();
9791        let attempt_config = config.clone();
9792        let attempt = std::thread::spawn(move || {
9793            NodeRuntime::open_cancellable(attempt_config, consensus, &[], &attempt_startup)
9794        });
9795
9796        blocked.recv().unwrap();
9797        startup.cancel(Instant::now() + Duration::from_secs(1));
9798        let (released, condition) = &*gate;
9799        *released.lock().unwrap() = true;
9800        condition.notify_all();
9801
9802        let error = attempt.join().unwrap().unwrap_err();
9803        assert!(
9804            error
9805                .to_string()
9806                .contains("startup cancelled during recorder decision inspection"),
9807            "{error}"
9808        );
9809        let log_store = FileLogStore::open_with_configuration(
9810            config.data_dir.join("consensus/log"),
9811            &config.cluster_id,
9812            config.epoch,
9813            config.log_initial_configuration.clone(),
9814        )
9815        .unwrap();
9816        assert_eq!(log_store.last_index().unwrap(), None);
9817    }
9818
9819    #[test]
9820    fn startup_cancellation_closes_mutation_admission_after_an_active_write_finishes() {
9821        let startup = StartupIoContext::new();
9822        let attempt_startup = startup.clone();
9823        let writes = Arc::new(AtomicUsize::new(0));
9824        let attempt_writes = Arc::clone(&writes);
9825        let (entered, active) = mpsc::sync_channel(1);
9826        let gate = Arc::new((Mutex::new(false), Condvar::new()));
9827        let attempt_gate = Arc::clone(&gate);
9828        let attempt = std::thread::spawn(move || {
9829            attempt_startup.persist("test persistence", || {
9830                entered.send(()).unwrap();
9831                let (released, condition) = &*attempt_gate;
9832                let mut released = released.lock().unwrap();
9833                while !*released {
9834                    released = condition.wait(released).unwrap();
9835                }
9836                attempt_writes.fetch_add(1, Ordering::AcqRel);
9837                Ok(())
9838            })
9839        });
9840
9841        active.recv().unwrap();
9842        startup.cancel(Instant::now() + Duration::from_secs(1));
9843        let (released, condition) = &*gate;
9844        *released.lock().unwrap() = true;
9845        condition.notify_all();
9846        attempt.join().unwrap().unwrap();
9847
9848        assert!(startup
9849            .persist("late persistence", || {
9850                writes.fetch_add(1, Ordering::AcqRel);
9851                Ok(())
9852            })
9853            .is_err());
9854        assert_eq!(writes.load(Ordering::Acquire), 1);
9855    }
9856
9857    #[test]
9858    fn embedded_config_accepts_matching_canonical_profile_ids() {
9859        for (cluster_id, profile) in [
9860            ("rhiza:graph:embedded", ExecutionProfile::Graph),
9861            ("rhiza:kv:embedded", ExecutionProfile::Kv),
9862        ] {
9863            let config = NodeConfig::new_embedded(
9864                cluster_id,
9865                "n1",
9866                std::env::temp_dir().join(profile.as_str()),
9867                1,
9868                1,
9869                ["n1", "n2", "n3"],
9870            )
9871            .unwrap()
9872            .with_execution_profile(profile)
9873            .unwrap();
9874
9875            assert_eq!(config.cluster_id(), cluster_id);
9876            assert_eq!(config.logical_cluster_id(), "embedded");
9877        }
9878    }
9879
9880    #[test]
9881    fn embedded_config_rejects_conflicting_canonical_profile_and_preserves_logical_ids() {
9882        let conflicting = NodeConfig::new_embedded(
9883            "rhiza:graph:embedded",
9884            "n1",
9885            std::env::temp_dir().join("conflicting-profile"),
9886            1,
9887            1,
9888            ["n1", "n2", "n3"],
9889        )
9890        .unwrap()
9891        .with_execution_profile(ExecutionProfile::Sqlite)
9892        .unwrap_err();
9893        assert!(matches!(
9894            conflicting,
9895            ConfigError::ClusterIdProfileMismatch {
9896                expected: ExecutionProfile::Sqlite,
9897                actual: ExecutionProfile::Graph,
9898            }
9899        ));
9900
9901        let logical = NodeConfig::new_embedded(
9902            "embedded",
9903            "n1",
9904            std::env::temp_dir().join("logical-profile"),
9905            1,
9906            1,
9907            ["n1", "n2", "n3"],
9908        )
9909        .unwrap();
9910        assert_eq!(logical.cluster_id(), "rhiza:sql:embedded");
9911        assert_eq!(logical.logical_cluster_id(), "embedded");
9912    }
9913
9914    #[test]
9915    fn node_error_classification_reports_observable_retry_semantics() {
9916        let cases = [
9917            (
9918                NodeError::InvalidRequest("missing key".into()),
9919                "invalid_request",
9920                ErrorCategory::InvalidRequest,
9921                false,
9922            ),
9923            (
9924                NodeError::PreconditionFailed("stale version".into()),
9925                "precondition_failed",
9926                ErrorCategory::Conflict,
9927                false,
9928            ),
9929            (
9930                NodeError::Unavailable("no quorum".into()),
9931                "unavailable",
9932                ErrorCategory::Unavailable,
9933                true,
9934            ),
9935            (
9936                NodeError::ResourceExhausted("result too large".into()),
9937                "resource_exhausted",
9938                ErrorCategory::ResourceExhausted,
9939                true,
9940            ),
9941            (
9942                NodeError::Invariant("invalid log".into()),
9943                "invariant_violation",
9944                ErrorCategory::Internal,
9945                false,
9946            ),
9947        ];
9948
9949        for (error, code, category, retryable) in cases {
9950            let classification = error.classification();
9951
9952            assert_eq!(classification.code(), code);
9953            assert_eq!(classification.category(), category);
9954            assert_eq!(classification.retryable(), retryable);
9955        }
9956    }
9957
9958    #[cfg(feature = "sql")]
9959    #[test]
9960    fn sql_batch_error_classification_preserves_statement_index_category() {
9961        let error = NodeError::InvalidSqlStatement {
9962            statement_index: 3,
9963            message: "syntax error".into(),
9964        };
9965
9966        let classification = error.classification();
9967
9968        assert_eq!(classification.code(), "invalid_request");
9969        assert_eq!(classification.category(), ErrorCategory::InvalidRequest);
9970        assert!(!classification.retryable());
9971    }
9972
9973    #[cfg(feature = "sql")]
9974    #[tokio::test]
9975    async fn node_error_http_response_preserves_contract() {
9976        let snapshot = RecoveryAnchor::new(
9977            "cluster",
9978            1,
9979            ConfigurationState::active(1, LogHash::digest(&[b"node-error-test-config"])),
9980            1,
9981            LogAnchor::new(1, LogHash::ZERO),
9982            SnapshotIdentity::new(
9983                "snapshot",
9984                LogHash::digest(&[b"node-error-test-snapshot"]),
9985                0,
9986                rhiza_sql::sql_executor_fingerprint().unwrap(),
9987            ),
9988        );
9989        let cases = vec![
9990            (
9991                NodeError::InvalidSqlStatement {
9992                    statement_index: 3,
9993                    message: "syntax error".into(),
9994                },
9995                axum::http::StatusCode::BAD_REQUEST,
9996                "invalid_request",
9997                false,
9998                Some(3),
9999            ),
10000            (
10001                NodeError::PreconditionFailed("stale version".into()),
10002                axum::http::StatusCode::CONFLICT,
10003                "precondition_failed",
10004                false,
10005                None,
10006            ),
10007            (
10008                NodeError::SnapshotRequired(Box::new(snapshot)),
10009                axum::http::StatusCode::SERVICE_UNAVAILABLE,
10010                "snapshot_required",
10011                false,
10012                None,
10013            ),
10014            (
10015                NodeError::Storage("disk failed".into()),
10016                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
10017                "storage_error",
10018                false,
10019                None,
10020            ),
10021        ];
10022
10023        for (node_error, status, code, retryable, statement_index) in cases {
10024            let response = node_error_response(node_error);
10025            assert_eq!(response.status(), status);
10026            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10027                .await
10028                .unwrap();
10029            let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
10030            let error: super::ClientErrorResponse = serde_json::from_value(value.clone()).unwrap();
10031            assert_eq!(error.code, code);
10032            assert_eq!(error.retryable, retryable);
10033            assert_eq!(error.statement_index, statement_index);
10034            assert!(value.get("category").is_none());
10035            assert!(matches!(
10036                error.message.as_str(),
10037                "request could not be processed"
10038                    | "request conflicts with current state"
10039                    | "service is temporarily unavailable"
10040                    | "internal server error"
10041            ));
10042        }
10043    }
10044
10045    #[cfg(feature = "sql")]
10046    #[tokio::test]
10047    async fn node_error_http_response_hides_display_details() {
10048        let detail = "/srv/rhiza/private/consensus/log: checksum mismatch";
10049        let cases = [
10050            (
10051                NodeError::Storage(detail.into()),
10052                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
10053                "storage_error",
10054                None,
10055                "internal server error",
10056            ),
10057            (
10058                NodeError::Reconciliation(detail.into()),
10059                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
10060                "reconciliation_error",
10061                None,
10062                "internal server error",
10063            ),
10064            (
10065                NodeError::InvalidSqlStatement {
10066                    statement_index: 7,
10067                    message: detail.into(),
10068                },
10069                axum::http::StatusCode::BAD_REQUEST,
10070                "invalid_request",
10071                Some(7),
10072                "request could not be processed",
10073            ),
10074        ];
10075
10076        for (node_error, status, code, statement_index, message) in cases {
10077            let response = node_error_response(node_error);
10078            assert_eq!(response.status(), status);
10079            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10080                .await
10081                .unwrap();
10082            let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10083            assert_eq!(error.code, code);
10084            assert_eq!(error.statement_index, statement_index);
10085            assert_eq!(error.message, message);
10086            assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
10087        }
10088    }
10089
10090    #[tokio::test]
10091    async fn client_json_error_response_uses_a_stable_message() {
10092        for (status, code) in [
10093            (axum::http::StatusCode::BAD_REQUEST, "invalid_json"),
10094            (
10095                axum::http::StatusCode::PAYLOAD_TOO_LARGE,
10096                "payload_too_large",
10097            ),
10098        ] {
10099            let response = super::client_json_error_response(status);
10100
10101            assert_eq!(response.status(), status);
10102            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10103                .await
10104                .unwrap();
10105            let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10106            assert_eq!(error.code, code);
10107            assert!(!error.retryable);
10108            assert_eq!(error.message, "request body is invalid");
10109            assert_eq!(error.statement_index, None);
10110        }
10111    }
10112
10113    #[tokio::test]
10114    async fn client_task_error_hides_join_error_details() {
10115        let detail = "private task detail\nforged log entry";
10116        let error = tokio::spawn(async move { panic!("{detail}") })
10117            .await
10118            .unwrap_err();
10119        let response = super::client_task_error(error);
10120
10121        assert_eq!(
10122            response.status(),
10123            axum::http::StatusCode::INTERNAL_SERVER_ERROR
10124        );
10125        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10126            .await
10127            .unwrap();
10128        let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10129        assert_eq!(error.code, "task_failed");
10130        assert!(!error.retryable);
10131        assert_eq!(error.message, "request task failed");
10132        assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
10133    }
10134
10135    #[cfg(feature = "sql")]
10136    #[tokio::test]
10137    async fn fatal_readiness_response_hides_fatal_reason() {
10138        let (_dir, runtime) = sql_test_runtime();
10139        let detail = "/srv/rhiza/private\nforged log entry";
10140        runtime.latch(NodeError::Storage(detail.into()));
10141        let response = super::runtime_readiness_response(&runtime).unwrap();
10142
10143        assert_eq!(
10144            response.status(),
10145            axum::http::StatusCode::INTERNAL_SERVER_ERROR
10146        );
10147        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10148            .await
10149            .unwrap();
10150        let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10151        assert_eq!(error.code, "fatal");
10152        assert!(!error.retryable);
10153        assert_eq!(error.message, "node is fatally unavailable");
10154        assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
10155    }
10156
10157    #[test]
10158    fn escaped_error_detail_escapes_control_characters() {
10159        let detail = "checksum mismatch\nforged entry\r\u{1b}[2J";
10160
10161        assert_eq!(
10162            super::escaped_error_detail(&detail),
10163            r"checksum mismatch\nforged entry\r\u{1b}[2J"
10164        );
10165    }
10166
10167    #[test]
10168    fn escaped_error_detail_bounds_escape_expansion() {
10169        let detail = "\n".repeat(super::MAX_ESCAPED_ERROR_DETAIL_BYTES / 2 + 1);
10170        let escaped = super::escaped_error_detail(&detail);
10171
10172        assert!(
10173            escaped.len() <= super::MAX_ESCAPED_ERROR_DETAIL_BYTES,
10174            "escaped detail must stay within the log budget"
10175        );
10176        assert!(
10177            escaped.ends_with(super::ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER),
10178            "truncated details must be explicit"
10179        );
10180        assert!(!escaped.contains('\n'));
10181    }
10182
10183    #[tokio::test]
10184    async fn client_error_responses_preserve_payload_and_authentication_wire_codes() {
10185        for (status, code, retryable, category) in [
10186            (
10187                axum::http::StatusCode::PAYLOAD_TOO_LARGE,
10188                "payload_too_large",
10189                false,
10190                ErrorCategory::ResourceExhausted,
10191            ),
10192            (
10193                axum::http::StatusCode::UNAUTHORIZED,
10194                "unauthorized",
10195                false,
10196                ErrorCategory::Authentication,
10197            ),
10198        ] {
10199            let response =
10200                super::client_error_response(status, code, retryable, "request failed", None);
10201            assert_eq!(response.status(), status);
10202            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10203                .await
10204                .unwrap();
10205            let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
10206            let error: super::ClientErrorResponse = serde_json::from_value(value.clone()).unwrap();
10207            assert_eq!(error.code, code);
10208            assert_eq!(error.retryable, retryable);
10209            assert!(value.get("category").is_none());
10210            assert_eq!(
10211                ErrorClassification::from_server_code(code, retryable).category(),
10212                category
10213            );
10214        }
10215    }
10216
10217    #[test]
10218    fn concurrent_read_barriers_registered_before_cutoff_share_one_generation() {
10219        let rounds = ReadBarrierRounds::new(Duration::ZERO);
10220        let cancelled = AtomicBool::new(false);
10221        let participants = (0..4).map(|_| rounds.join().unwrap()).collect::<Vec<_>>();
10222        let generation = participants[0].generation();
10223        assert!(participants[0].is_leader());
10224        assert!(participants[1..]
10225            .iter()
10226            .all(|participant| !participant.is_leader() && participant.generation() == generation));
10227
10228        let calls = AtomicUsize::new(0);
10229        let mut publication = participants[0].publication().unwrap();
10230        publication.wait_turn(&cancelled).unwrap();
10231        publication.start(&cancelled).unwrap();
10232        let anchor = LogAnchor::new(7, LogHash::digest(&[b"shared-barrier"]));
10233        calls.fetch_add(1, Ordering::Relaxed);
10234        publication.publish(Ok(anchor));
10235
10236        assert_eq!(calls.load(Ordering::Relaxed), 1);
10237        for participant in &participants[1..] {
10238            assert_eq!(participant.wait(&cancelled).unwrap(), anchor);
10239        }
10240    }
10241
10242    #[test]
10243    fn read_barrier_arriving_after_running_cutoff_uses_next_generation() {
10244        let rounds = Arc::new(ReadBarrierRounds::new(Duration::ZERO));
10245        let cancelled = Arc::new(AtomicBool::new(false));
10246        let first = rounds.join().unwrap();
10247        let first_generation = first.generation();
10248        let (running_tx, running_rx) = mpsc::channel();
10249        let (release_tx, release_rx) = mpsc::channel();
10250        let first_cancelled = Arc::clone(&cancelled);
10251        let first_worker = std::thread::spawn(move || {
10252            let mut publication = first.publication().unwrap();
10253            publication.wait_turn(&first_cancelled).unwrap();
10254            publication.start(&first_cancelled).unwrap();
10255            running_tx.send(()).unwrap();
10256            release_rx.recv().unwrap();
10257            let anchor = LogAnchor::new(1, LogHash::digest(&[b"first"]));
10258            publication.publish(Ok(anchor));
10259            anchor
10260        });
10261        running_rx.recv().unwrap();
10262
10263        let late = rounds.join().unwrap();
10264        assert!(late.is_leader());
10265        assert_eq!(late.generation(), first_generation + 1);
10266        release_tx.send(()).unwrap();
10267        assert_eq!(first_worker.join().unwrap().index(), 1);
10268
10269        let mut publication = late.publication().unwrap();
10270        publication.wait_turn(&cancelled).unwrap();
10271        publication.start(&cancelled).unwrap();
10272        let second = LogAnchor::new(2, LogHash::digest(&[b"second"]));
10273        publication.publish(Ok(second));
10274        assert_eq!(late.wait(&cancelled).unwrap(), second);
10275    }
10276
10277    #[test]
10278    fn completed_read_barrier_is_not_reused_and_predecessor_failure_retries_independently() {
10279        let rounds = ReadBarrierRounds::new(Duration::ZERO);
10280        let cancelled = AtomicBool::new(false);
10281        let failed = rounds.join().unwrap();
10282        let failed_generation = failed.generation();
10283        let mut publication = failed.publication().unwrap();
10284        publication.wait_turn(&cancelled).unwrap();
10285        publication.start(&cancelled).unwrap();
10286        publication.publish(Err(NodeError::Unavailable("no quorum".into())));
10287        assert!(matches!(
10288            failed.wait(&cancelled),
10289            Err(NodeError::Unavailable(_))
10290        ));
10291
10292        let retry = rounds.join().unwrap();
10293        assert!(retry.is_leader());
10294        assert_eq!(retry.generation(), failed_generation + 1);
10295        let mut publication = retry.publication().unwrap();
10296        publication.wait_turn(&cancelled).unwrap();
10297        publication.start(&cancelled).unwrap();
10298        let anchor = LogAnchor::new(1, LogHash::digest(&[b"retry"]));
10299        publication.publish(Ok(anchor));
10300        assert_eq!(retry.wait(&cancelled).unwrap(), anchor);
10301
10302        let later = rounds.join().unwrap();
10303        assert!(later.is_leader());
10304        assert_eq!(later.generation(), retry.generation() + 1);
10305    }
10306
10307    #[test]
10308    fn read_barrier_leader_drop_and_global_cancel_wake_waiters() {
10309        let rounds = Arc::new(ReadBarrierRounds::new(Duration::ZERO));
10310        let cancelled = Arc::new(AtomicBool::new(false));
10311        let abandoned = rounds.join().unwrap();
10312        let follower = rounds.join().unwrap();
10313        drop(abandoned.publication().unwrap());
10314        assert!(matches!(
10315            follower.wait(&cancelled),
10316            Err(NodeError::Unavailable(_))
10317        ));
10318
10319        let leader = rounds.join().unwrap();
10320        let waiting = rounds.join().unwrap();
10321        let waiting_cancelled = Arc::clone(&cancelled);
10322        let waiter = std::thread::spawn(move || waiting.wait(&waiting_cancelled));
10323        cancelled.store(true, Ordering::Release);
10324        rounds.cancel_waiters();
10325        assert!(matches!(
10326            waiter.join().unwrap(),
10327            Err(NodeError::Unavailable(_))
10328        ));
10329        drop(leader.publication().unwrap());
10330    }
10331
10332    #[test]
10333    fn sql_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
10334        let (_dir, mut runtime) = sql_test_runtime();
10335        runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
10336        let runtime = Arc::new(runtime);
10337        let start = Arc::new(Barrier::new(4));
10338        let workers = (0..4)
10339            .map(|_| {
10340                let runtime = Arc::clone(&runtime);
10341                let start = Arc::clone(&start);
10342                std::thread::spawn(move || {
10343                    start.wait();
10344                    runtime.read("missing", ReadConsistency::ReadBarrier)
10345                })
10346            })
10347            .collect::<Vec<_>>();
10348
10349        let responses = workers
10350            .into_iter()
10351            .map(|worker| worker.join().unwrap().unwrap())
10352            .collect::<Vec<_>>();
10353        assert!(responses
10354            .iter()
10355            .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
10356        assert_eq!(runtime.log_store().last_index().unwrap(), None);
10357    }
10358
10359    #[test]
10360    fn read_barrier_anchor_remains_valid_when_materialized_tip_advances() {
10361        let (_dir, runtime) = sql_test_runtime();
10362        let anchor = runtime.establish_read_barrier().unwrap();
10363        let write = runtime.write("request-1", "alpha", "one").unwrap();
10364        assert!(write.applied_index > anchor.index());
10365
10366        let _commit = runtime.lock_commit().unwrap();
10367        runtime.ensure_ready().unwrap();
10368        runtime.ensure_writes_active().unwrap();
10369        runtime
10370            .validate_read_barrier_descendant_locked(anchor)
10371            .unwrap();
10372        let read = runtime.read_local("alpha", Some(anchor.index())).unwrap();
10373
10374        assert_eq!(read.value.as_deref(), Some("one"));
10375        assert_eq!(read.applied_index, write.applied_index);
10376        assert_eq!(read.hash, write.hash);
10377    }
10378
10379    #[test]
10380    fn checkpoint_publication_does_not_close_runtime_readiness_or_writes() {
10381        let (_dir, runtime) = sql_test_runtime();
10382        runtime.checkpointing.store(true, Ordering::Release);
10383
10384        assert!(runtime.is_ready());
10385        runtime.ensure_ready().unwrap();
10386        let write = runtime.write("request-1", "alpha", "one").unwrap();
10387
10388        assert_eq!(write.applied_index, 1);
10389        assert_eq!(
10390            runtime.read("alpha", ReadConsistency::Local).unwrap().value,
10391            Some("one".into())
10392        );
10393    }
10394
10395    #[cfg(feature = "graph")]
10396    #[test]
10397    fn graph_read_barrier_checks_materialized_tip_once_before_snapshot() {
10398        let (_dir, runtime) = graph_test_runtime();
10399
10400        let response = runtime
10401            .get_graph_document("missing", ReadConsistency::ReadBarrier)
10402            .unwrap();
10403
10404        assert_eq!(response.applied_index, 0);
10405        assert_eq!(response.hash, LogHash::ZERO);
10406        assert_eq!(runtime.materialized_tip_checks.load(Ordering::Relaxed), 1);
10407    }
10408
10409    #[cfg(feature = "graph")]
10410    #[test]
10411    fn graph_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
10412        let (_dir, mut runtime) = graph_test_runtime();
10413        runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
10414        let runtime = Arc::new(runtime);
10415        let start = Arc::new(Barrier::new(4));
10416        let workers = (0..4)
10417            .map(|_| {
10418                let runtime = Arc::clone(&runtime);
10419                let start = Arc::clone(&start);
10420                std::thread::spawn(move || {
10421                    start.wait();
10422                    runtime.get_graph_document("missing", ReadConsistency::ReadBarrier)
10423                })
10424            })
10425            .collect::<Vec<_>>();
10426
10427        let responses = workers
10428            .into_iter()
10429            .map(|worker| worker.join().unwrap().unwrap())
10430            .collect::<Vec<_>>();
10431        assert!(responses
10432            .iter()
10433            .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
10434        assert_eq!(runtime.log_store().last_index().unwrap(), None);
10435    }
10436
10437    #[cfg(feature = "graph")]
10438    #[test]
10439    fn graph_read_barrier_releases_commit_lock_before_backend_snapshot() {
10440        let (_dir, mut runtime) = graph_test_runtime();
10441        let initial = runtime
10442            .mutate_graph(
10443                GraphCommandV1::put_document(
10444                    "request-1",
10445                    "document-1",
10446                    GraphValueV1::String("one".into()),
10447                )
10448                .unwrap(),
10449            )
10450            .unwrap();
10451        let entered = Arc::new(Barrier::new(2));
10452        let release = Arc::new(Barrier::new(2));
10453        runtime.read_barrier_before_snapshot_hook = Some(Arc::new({
10454            let entered = Arc::clone(&entered);
10455            let release = Arc::clone(&release);
10456            move || {
10457                entered.wait();
10458                release.wait();
10459            }
10460        }));
10461        let runtime = Arc::new(runtime);
10462        let reader = {
10463            let runtime = Arc::clone(&runtime);
10464            std::thread::spawn(move || {
10465                runtime.get_graph_document("document-1", ReadConsistency::ReadBarrier)
10466            })
10467        };
10468        entered.wait();
10469        let (advanced_tx, advanced_rx) = mpsc::channel();
10470        let writer = {
10471            let runtime = Arc::clone(&runtime);
10472            std::thread::spawn(move || {
10473                let outcome = runtime
10474                    .mutate_graph(
10475                        GraphCommandV1::put_document(
10476                            "request-2",
10477                            "document-1",
10478                            GraphValueV1::String("two".into()),
10479                        )
10480                        .unwrap(),
10481                    )
10482                    .unwrap();
10483                advanced_tx
10484                    .send((outcome.applied_index(), outcome.hash()))
10485                    .unwrap();
10486                outcome
10487            })
10488        };
10489
10490        let advanced_before_snapshot = advanced_rx.recv_timeout(Duration::from_secs(2));
10491        release.wait();
10492        let written = writer.join().unwrap();
10493        let read = reader.join().unwrap().unwrap();
10494
10495        assert!(
10496            advanced_before_snapshot.is_ok(),
10497            "graph write must advance while the read is paused before its backend snapshot"
10498        );
10499        assert!(read.applied_index >= initial.applied_index());
10500        if read.applied_index == initial.applied_index() {
10501            assert_eq!(read.hash, initial.hash());
10502        }
10503        assert_eq!(read.applied_index, written.applied_index());
10504        assert_eq!(read.hash, written.hash());
10505    }
10506
10507    #[cfg(feature = "graph")]
10508    #[test]
10509    fn read_barrier_rejects_same_index_snapshot_with_different_hash() {
10510        let (_dir, runtime) = graph_test_runtime();
10511        let anchor = LogAnchor::new(7, LogHash::digest(&[b"barrier-anchor"]));
10512        let observed = LogAnchor::new(7, LogHash::digest(&[b"divergent-snapshot"]));
10513
10514        assert!(matches!(
10515            runtime.validate_read_barrier_snapshot(anchor, observed),
10516            Err(NodeError::Invariant(message))
10517                if message.contains("snapshot tip hash differs")
10518        ));
10519        assert!(runtime.is_fatal());
10520    }
10521
10522    #[cfg(feature = "kv")]
10523    #[test]
10524    fn kv_read_barrier_checks_materialized_tip_once_before_snapshot() {
10525        let (_dir, runtime) = kv_test_runtime();
10526
10527        let response = runtime
10528            .get_kv(b"missing", ReadConsistency::ReadBarrier)
10529            .unwrap();
10530
10531        assert_eq!(response.applied_index, 0);
10532        assert_eq!(response.hash, LogHash::ZERO);
10533        assert_eq!(runtime.materialized_tip_checks.load(Ordering::Relaxed), 1);
10534    }
10535
10536    #[cfg(feature = "kv")]
10537    #[test]
10538    fn kv_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
10539        let (_dir, mut runtime) = kv_test_runtime();
10540        runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
10541        let runtime = Arc::new(runtime);
10542        let start = Arc::new(Barrier::new(4));
10543        let workers = (0..4)
10544            .map(|_| {
10545                let runtime = Arc::clone(&runtime);
10546                let start = Arc::clone(&start);
10547                std::thread::spawn(move || {
10548                    start.wait();
10549                    runtime.get_kv(b"missing", ReadConsistency::ReadBarrier)
10550                })
10551            })
10552            .collect::<Vec<_>>();
10553
10554        let responses = workers
10555            .into_iter()
10556            .map(|worker| worker.join().unwrap().unwrap())
10557            .collect::<Vec<_>>();
10558        assert!(responses
10559            .iter()
10560            .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
10561        assert_eq!(runtime.log_store().last_index().unwrap(), None);
10562    }
10563
10564    #[cfg(feature = "kv")]
10565    #[test]
10566    fn kv_read_barrier_releases_commit_lock_before_backend_snapshot() {
10567        let (_dir, mut runtime) = kv_test_runtime();
10568        let initial = runtime
10569            .mutate_kv(KvCommandV1::put("request-1", b"key".to_vec(), b"one".to_vec()).unwrap())
10570            .unwrap();
10571        let entered = Arc::new(Barrier::new(2));
10572        let release = Arc::new(Barrier::new(2));
10573        runtime.read_barrier_before_snapshot_hook = Some(Arc::new({
10574            let entered = Arc::clone(&entered);
10575            let release = Arc::clone(&release);
10576            move || {
10577                entered.wait();
10578                release.wait();
10579            }
10580        }));
10581        let runtime = Arc::new(runtime);
10582        let reader = {
10583            let runtime = Arc::clone(&runtime);
10584            std::thread::spawn(move || runtime.get_kv(b"key", ReadConsistency::ReadBarrier))
10585        };
10586        entered.wait();
10587        let (advanced_tx, advanced_rx) = mpsc::channel();
10588        let writer = {
10589            let runtime = Arc::clone(&runtime);
10590            std::thread::spawn(move || {
10591                let outcome = runtime
10592                    .mutate_kv(
10593                        KvCommandV1::put("request-2", b"key".to_vec(), b"two".to_vec()).unwrap(),
10594                    )
10595                    .unwrap();
10596                advanced_tx
10597                    .send((outcome.applied_index(), outcome.hash()))
10598                    .unwrap();
10599                outcome
10600            })
10601        };
10602
10603        let advanced_before_snapshot = advanced_rx.recv_timeout(Duration::from_secs(2));
10604        release.wait();
10605        let written = writer.join().unwrap();
10606        let read = reader.join().unwrap().unwrap();
10607
10608        assert!(
10609            advanced_before_snapshot.is_ok(),
10610            "KV write must advance while the read is paused before its backend snapshot"
10611        );
10612        assert!(read.applied_index >= initial.applied_index());
10613        if read.applied_index == initial.applied_index() {
10614            assert_eq!(read.hash, initial.hash());
10615        }
10616        assert_eq!(read.applied_index, written.applied_index());
10617        assert_eq!(read.hash, written.hash());
10618    }
10619
10620    #[test]
10621    fn client_authentication_rejects_empty_expected_token() {
10622        let mut headers = HeaderMap::new();
10623        headers.insert(VERSION_HEADER, HeaderValue::from_static(PROTOCOL_VERSION));
10624        headers.insert("authorization", HeaderValue::from_static("Bearer "));
10625
10626        assert!(!client_authenticated(&headers, ""));
10627    }
10628
10629    #[test]
10630    fn recorder_record_rejects_oversized_inline_command() {
10631        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
10632        let command = StoredCommand::new(
10633            EntryType::Command,
10634            vec![0_u8; MAX_COMMAND_BYTES.saturating_add(1)],
10635        );
10636        let request = RecordRequest {
10637            cluster_id: "rhiza:sql:node-unit-test".into(),
10638            epoch: 1,
10639            config_id: 1,
10640            config_digest: membership.digest(),
10641            slot: 1,
10642            step: 1,
10643            proposal: Proposal::new(
10644                ProposalPriority::MAX,
10645                "n1",
10646                1,
10647                AcceptedValue::from_command(
10648                    "rhiza:sql:node-unit-test",
10649                    1,
10650                    1,
10651                    1,
10652                    LogHash::ZERO,
10653                    &command,
10654                ),
10655            ),
10656            command: Some(command),
10657        };
10658
10659        assert!(!valid_recorder_record(&request));
10660    }
10661
10662    #[test]
10663    fn sync_flush_retry_doubles_to_a_jitter_free_cap() {
10664        let mut delay = SYNC_FLUSH_RETRY_INITIAL;
10665        let mut delays = Vec::new();
10666        for _ in 0..7 {
10667            delays.push(delay);
10668            delay = next_sync_flush_retry(delay);
10669        }
10670
10671        assert_eq!(
10672            delays,
10673            [50, 100, 200, 400, 800, 1_000, 1_000].map(Duration::from_millis)
10674        );
10675    }
10676
10677    #[test]
10678    fn blocking_operation_offloads_on_current_thread_runtime() {
10679        let runtime = tokio::runtime::Builder::new_current_thread()
10680            .build()
10681            .unwrap();
10682        runtime.block_on(async {
10683            let caller = std::thread::current().id();
10684            let worker = run_read_operation(ReadConsistency::Local, || std::thread::current().id())
10685                .await
10686                .unwrap();
10687
10688            assert_ne!(worker, caller);
10689        });
10690    }
10691
10692    #[test]
10693    fn blocking_operation_runs_inline_on_multi_thread_runtime() {
10694        let runtime = tokio::runtime::Builder::new_multi_thread()
10695            .worker_threads(2)
10696            .build()
10697            .unwrap();
10698        runtime.block_on(async {
10699            let caller = std::thread::current().id();
10700            let worker = run_read_operation(ReadConsistency::AppliedIndex(1), || {
10701                std::thread::current().id()
10702            })
10703            .await
10704            .unwrap();
10705
10706            assert_eq!(worker, caller);
10707        });
10708    }
10709
10710    #[test]
10711    fn read_barrier_offloads_on_multi_thread_runtime() {
10712        let runtime = tokio::runtime::Builder::new_multi_thread()
10713            .worker_threads(2)
10714            .build()
10715            .unwrap();
10716        runtime.block_on(async {
10717            let caller = std::thread::current().id();
10718            let worker =
10719                run_read_operation(ReadConsistency::ReadBarrier, || std::thread::current().id())
10720                    .await
10721                    .unwrap();
10722
10723            assert_ne!(worker, caller);
10724        });
10725    }
10726
10727    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10728    async fn node_service_adaptive_sql_read_returns_point_and_query_results() {
10729        let (_dir, runtime) = sql_test_runtime();
10730        let service = NodeService::new(Arc::new(runtime), None);
10731
10732        let point = service
10733            .read("missing", ReadConsistency::Local)
10734            .await
10735            .unwrap();
10736        let query = service
10737            .query(
10738                SqlStatement {
10739                    sql: "SELECT ?1 AS value".into(),
10740                    parameters: vec![SqlValue::Integer(7)],
10741                },
10742                ReadConsistency::AppliedIndex(0),
10743                1,
10744            )
10745            .await
10746            .unwrap();
10747
10748        assert_eq!(point.value, None);
10749        assert_eq!(query.columns, vec!["value"]);
10750        assert_eq!(query.rows, vec![vec![SqlValue::Integer(7)]]);
10751    }
10752
10753    #[test]
10754    fn node_service_adaptive_sql_read_stays_inline_and_recovers_direct_panic() {
10755        let (_dir, runtime) = sql_test_runtime();
10756        let service = NodeService::new(Arc::new(runtime), None);
10757        let runtime = tokio::runtime::Builder::new_multi_thread()
10758            .worker_threads(2)
10759            .build()
10760            .unwrap();
10761        runtime.block_on(async {
10762            let caller = std::thread::current().id();
10763            let worker = service
10764                .run_sql_read_operation(ReadConsistency::Local, || std::thread::current().id())
10765                .await
10766                .unwrap();
10767
10768            assert_eq!(worker, caller);
10769            assert_eq!(
10770                service
10771                    .sql_reads_in_flight
10772                    .load(std::sync::atomic::Ordering::Acquire),
10773                0
10774            );
10775        });
10776
10777        let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
10778            runtime.block_on(
10779                service.run_sql_read_operation(ReadConsistency::AppliedIndex(0), || -> () {
10780                    panic!("inline SQL read panic")
10781                }),
10782            )
10783        }));
10784        assert!(panic.is_err());
10785        assert_eq!(
10786            service
10787                .sql_reads_in_flight
10788                .load(std::sync::atomic::Ordering::Acquire),
10789            0
10790        );
10791    }
10792
10793    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10794    async fn node_service_adaptive_sql_read_offloads_overlap_and_recovers_join_error() {
10795        let (_dir, runtime) = sql_test_runtime();
10796        let service = NodeService::new(Arc::new(runtime), None);
10797        let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
10798        let (release_tx, release_rx) = std::sync::mpsc::channel();
10799        let first_service = service.clone();
10800        let first = tokio::spawn(async move {
10801            first_service
10802                .run_sql_read_operation(ReadConsistency::Local, move || {
10803                    entered_tx.send(()).unwrap();
10804                    release_rx.recv().unwrap();
10805                })
10806                .await
10807                .unwrap();
10808        });
10809        entered_rx.await.unwrap();
10810        assert_eq!(
10811            service
10812                .sql_reads_in_flight
10813                .load(std::sync::atomic::Ordering::Acquire),
10814            1
10815        );
10816
10817        let caller = std::thread::current().id();
10818        let worker = service
10819            .run_sql_read_operation(ReadConsistency::AppliedIndex(0), || {
10820                std::thread::current().id()
10821            })
10822            .await
10823            .unwrap();
10824        assert_ne!(worker, caller);
10825        assert_eq!(
10826            service
10827                .sql_reads_in_flight
10828                .load(std::sync::atomic::Ordering::Acquire),
10829            1
10830        );
10831
10832        let error = service
10833            .run_sql_read_operation(ReadConsistency::Local, || -> () {
10834                panic!("contended SQL read panic")
10835            })
10836            .await
10837            .unwrap_err();
10838        assert!(error.is_panic());
10839        assert_eq!(
10840            service
10841                .sql_reads_in_flight
10842                .load(std::sync::atomic::Ordering::Acquire),
10843            1
10844        );
10845
10846        release_tx.send(()).unwrap();
10847        first.await.unwrap();
10848        assert_eq!(
10849            service
10850                .sql_reads_in_flight
10851                .load(std::sync::atomic::Ordering::Acquire),
10852            0
10853        );
10854    }
10855
10856    #[test]
10857    fn node_service_adaptive_sql_read_offloads_on_current_thread() {
10858        let (_dir, node) = sql_test_runtime();
10859        let service = NodeService::new(Arc::new(node), None);
10860        let runtime = tokio::runtime::Builder::new_current_thread()
10861            .build()
10862            .unwrap();
10863        runtime.block_on(async {
10864            let caller = std::thread::current().id();
10865            let worker = service
10866                .run_sql_read_operation(ReadConsistency::Local, || std::thread::current().id())
10867                .await
10868                .unwrap();
10869
10870            assert_ne!(worker, caller);
10871            assert_eq!(
10872                service
10873                    .sql_reads_in_flight
10874                    .load(std::sync::atomic::Ordering::Acquire),
10875                0
10876            );
10877        });
10878    }
10879
10880    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10881    async fn node_service_read_barrier_offloads_without_counting_fast_reads() {
10882        let (_dir, runtime) = sql_test_runtime();
10883        let service = NodeService::new(Arc::new(runtime), None);
10884        let caller = std::thread::current().id();
10885        let worker = service
10886            .run_sql_read_operation(ReadConsistency::ReadBarrier, || std::thread::current().id())
10887            .await
10888            .unwrap();
10889
10890        assert_ne!(worker, caller);
10891        assert_eq!(
10892            service
10893                .sql_reads_in_flight
10894                .load(std::sync::atomic::Ordering::Acquire),
10895            0
10896        );
10897    }
10898
10899    #[test]
10900    fn embedded_sql_query_keeps_raw_budget_without_http_json_budget() {
10901        let (_dir, runtime) = sql_test_runtime();
10902        let response = runtime
10903            .query_sql(
10904                &SqlStatement {
10905                    sql: "SELECT replace(hex(zeroblob(700000)), '00', char(1)) AS value".into(),
10906                    parameters: Vec::new(),
10907                },
10908                ReadConsistency::Local,
10909                1,
10910            )
10911            .unwrap();
10912
10913        assert!(serde_json::to_vec(&response).unwrap().len() > MAX_SQL_RESPONSE_BYTES);
10914    }
10915
10916    #[test]
10917    fn sql_http_response_rejects_encoded_body_over_limit() {
10918        let response = SqlQueryResponse {
10919            columns: vec!["value".into()],
10920            rows: vec![vec![SqlValue::Text("\u{1}".repeat(700_000))]],
10921            applied_index: 0,
10922            hash: LogHash::ZERO,
10923        };
10924
10925        assert_eq!(
10926            sql_query_http_response(response).status(),
10927            axum::http::StatusCode::BAD_REQUEST
10928        );
10929    }
10930
10931    #[cfg(feature = "graph")]
10932    #[test]
10933    fn graph_response_work_holds_client_capacity_until_completion() {
10934        let slots = std::sync::Arc::new(tokio::sync::Semaphore::new(1));
10935        let permit = std::sync::Arc::new(slots.clone().try_acquire_owned().unwrap());
10936
10937        let capacity_exhausted_during_response =
10938            with_graph_client_permit(permit, || slots.clone().try_acquire_owned().is_err());
10939
10940        assert!(capacity_exhausted_during_response);
10941        assert!(slots.try_acquire().is_ok());
10942    }
10943
10944    #[cfg(feature = "graph")]
10945    #[test]
10946    fn graph_client_query_error_returns_400_without_latching_readiness() {
10947        let (_dir, runtime) = graph_test_runtime();
10948
10949        let error = runtime.map_graph_read_error(rhiza_graph::Error::InvalidCommand(
10950            "unknown property".into(),
10951        ));
10952        let response = node_error_response(error);
10953
10954        assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
10955        assert!(runtime.is_ready());
10956        assert!(!runtime.is_fatal());
10957    }
10958
10959    #[cfg(feature = "graph")]
10960    #[test]
10961    fn graph_resource_exhaustion_returns_503_without_latching_readiness() {
10962        let (_dir, runtime) = graph_test_runtime();
10963
10964        let error = runtime.map_graph_read_error(rhiza_graph::Error::ResourceExhausted(
10965            "buffer pool is full".into(),
10966        ));
10967        let response = node_error_response(error);
10968
10969        assert_eq!(
10970            response.status(),
10971            axum::http::StatusCode::SERVICE_UNAVAILABLE
10972        );
10973        assert!(runtime.is_ready());
10974        assert!(!runtime.is_fatal());
10975    }
10976
10977    #[cfg(feature = "kv")]
10978    #[test]
10979    fn kv_resource_exhaustion_returns_503_without_latching_readiness() {
10980        let (_dir, runtime) = kv_test_runtime();
10981
10982        let error = runtime.map_kv_read_error(rhiza_kv::Error::ResourceExhausted(
10983            "scan result is too large".into(),
10984        ));
10985        let response = node_error_response(error);
10986
10987        assert_eq!(
10988            response.status(),
10989            axum::http::StatusCode::SERVICE_UNAVAILABLE
10990        );
10991        assert!(runtime.is_ready());
10992        assert!(!runtime.is_fatal());
10993    }
10994
10995    #[cfg(feature = "graph")]
10996    #[test]
10997    fn graph_batch_coalesces_exact_retry_and_isolates_conflicting_duplicate() {
10998        let (_dir, runtime) = graph_test_runtime();
10999        let canonical =
11000            GraphCommandV1::put_document("same", "document", GraphValueV1::String("first".into()))
11001                .unwrap();
11002        let conflict = GraphCommandV1::put_document(
11003            "same",
11004            "document",
11005            GraphValueV1::String("conflict".into()),
11006        )
11007        .unwrap();
11008        let unrelated =
11009            GraphCommandV1::put_document("other", "other", GraphValueV1::U64(2)).unwrap();
11010        let results = runtime
11011            .mutate_graph_batch(vec![canonical.clone(), canonical, conflict, unrelated])
11012            .unwrap();
11013
11014        let canonical = results[0].as_ref().unwrap().applied_index();
11015        assert_eq!(results[1].as_ref().unwrap().applied_index(), canonical);
11016        assert!(matches!(
11017            results[2],
11018            Err(super::NodeError::InvalidRequest(_))
11019        ));
11020        assert_eq!(results[3].as_ref().unwrap().applied_index(), canonical);
11021        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11022        assert!(runtime.is_ready());
11023    }
11024
11025    #[cfg(feature = "kv")]
11026    #[test]
11027    fn kv_batch_coalesces_exact_retry_and_isolates_conflicting_duplicate() {
11028        let (_dir, runtime) = kv_test_runtime();
11029        let canonical = KvCommandV1::put("same", b"key".to_vec(), b"first".to_vec()).unwrap();
11030        let conflict = KvCommandV1::put("same", b"key".to_vec(), b"conflict".to_vec()).unwrap();
11031        let unrelated = KvCommandV1::put("other", b"other".to_vec(), b"second".to_vec()).unwrap();
11032        let results = runtime
11033            .mutate_kv_batch(vec![canonical.clone(), canonical, conflict, unrelated])
11034            .unwrap();
11035
11036        let canonical = results[0].as_ref().unwrap().applied_index();
11037        assert_eq!(results[1].as_ref().unwrap().applied_index(), canonical);
11038        assert!(matches!(
11039            results[2],
11040            Err(super::NodeError::InvalidRequest(_))
11041        ));
11042        assert_eq!(results[3].as_ref().unwrap().applied_index(), canonical);
11043        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11044        assert!(runtime.is_ready());
11045    }
11046
11047    #[cfg(feature = "kv")]
11048    #[test]
11049    fn kv_group_commit_coalesces_four_waiting_64_member_calls_into_one_qlog() {
11050        let (_dir, runtime) = kv_test_runtime();
11051        let runtime = Arc::new(runtime);
11052        let commit = runtime.lock_commit().unwrap();
11053        let start = Arc::new(Barrier::new(5));
11054        let workers = (0..4)
11055            .map(|call| {
11056                let runtime = Arc::clone(&runtime);
11057                let start = Arc::clone(&start);
11058                std::thread::spawn(move || {
11059                    let commands = (0..64)
11060                        .map(|member| {
11061                            let id = call * 64 + member;
11062                            KvCommandV1::put(
11063                                format!("kv-group-{id}"),
11064                                format!("key-{id}").into_bytes(),
11065                                vec![u8::try_from(call).unwrap(); 128],
11066                            )
11067                            .unwrap()
11068                        })
11069                        .collect();
11070                    start.wait();
11071                    runtime.mutate_kv_batch(commands)
11072                })
11073            })
11074            .collect::<Vec<_>>();
11075        start.wait();
11076        runtime
11077            .kv_group_commit
11078            .wait_for_pending_calls(4, Duration::from_secs(5));
11079        drop(commit);
11080
11081        let responses = workers
11082            .into_iter()
11083            .map(|worker| worker.join().unwrap().unwrap())
11084            .collect::<Vec<_>>();
11085        let anchors = responses
11086            .iter()
11087            .flatten()
11088            .map(|result| {
11089                let outcome = result.as_ref().unwrap();
11090                (outcome.applied_index(), outcome.hash())
11091            })
11092            .collect::<std::collections::HashSet<_>>();
11093        assert_eq!(anchors.len(), 1);
11094        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11095    }
11096
11097    #[cfg(feature = "kv")]
11098    #[test]
11099    fn kv_group_commit_rejects_public_257_member_call_before_writing() {
11100        let (_dir, runtime) = kv_test_runtime();
11101        let commands = (0..257)
11102            .map(|id| {
11103                KvCommandV1::put(
11104                    format!("kv-over-{id}"),
11105                    format!("key-{id}").into_bytes(),
11106                    b"value".to_vec(),
11107                )
11108                .unwrap()
11109            })
11110            .collect();
11111
11112        let error = runtime.mutate_kv_batch(commands).unwrap_err();
11113
11114        assert!(matches!(error, NodeError::InvalidRequest(_)));
11115        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11116        assert!(runtime
11117            .kv_group_commit
11118            .state
11119            .lock()
11120            .unwrap()
11121            .pending
11122            .is_empty());
11123    }
11124
11125    #[cfg(feature = "kv")]
11126    #[test]
11127    fn kv_group_commit_lone_call_completes_and_leaves_queue_idle() {
11128        let (_dir, runtime) = kv_test_runtime();
11129
11130        let outcome = runtime
11131            .mutate_kv(KvCommandV1::put("kv-lone", b"key".to_vec(), b"value".to_vec()).unwrap())
11132            .unwrap();
11133
11134        assert_eq!(outcome.applied_index(), 1);
11135        let state = runtime
11136            .kv_group_commit
11137            .state
11138            .lock()
11139            .unwrap_or_else(std::sync::PoisonError::into_inner);
11140        assert!(state.pending.is_empty());
11141        assert_eq!(state.pending_encoded_bytes, 0);
11142        assert!(!state.leader_active);
11143    }
11144
11145    #[cfg(feature = "kv")]
11146    #[test]
11147    fn kv_group_commit_shutdown_wakes_waiters_without_writing() {
11148        let (_dir, runtime) = kv_test_runtime();
11149        let runtime = Arc::new(runtime);
11150        let commit = runtime.lock_commit().unwrap();
11151        let start = Arc::new(Barrier::new(3));
11152        let workers = (0..2)
11153            .map(|id| {
11154                let runtime = Arc::clone(&runtime);
11155                let start = Arc::clone(&start);
11156                std::thread::spawn(move || {
11157                    start.wait();
11158                    runtime.mutate_kv(
11159                        KvCommandV1::put(
11160                            format!("kv-shutdown-{id}"),
11161                            format!("key-{id}").into_bytes(),
11162                            b"value".to_vec(),
11163                        )
11164                        .unwrap(),
11165                    )
11166                })
11167            })
11168            .collect::<Vec<_>>();
11169        start.wait();
11170        runtime
11171            .kv_group_commit
11172            .wait_for_pending_calls(2, Duration::from_secs(5));
11173
11174        runtime.cancel_operations();
11175        drop(commit);
11176
11177        for worker in workers {
11178            assert!(matches!(
11179                worker.join().unwrap(),
11180                Err(NodeError::Unavailable(_))
11181            ));
11182        }
11183        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11184        let state = runtime
11185            .kv_group_commit
11186            .state
11187            .lock()
11188            .unwrap_or_else(std::sync::PoisonError::into_inner);
11189        assert!(state.pending.is_empty());
11190        assert_eq!(state.pending_encoded_bytes, 0);
11191        assert!(!state.leader_active);
11192    }
11193
11194    #[cfg(feature = "kv")]
11195    #[test]
11196    fn kv_group_commit_preserves_cross_call_retry_conflict_and_new_result_offsets() {
11197        let (_dir, runtime) = kv_test_runtime();
11198        let runtime = Arc::new(runtime);
11199        let stored = KvCommandV1::put(
11200            "kv-stored",
11201            b"stored-key".to_vec(),
11202            b"stored-value".to_vec(),
11203        )
11204        .unwrap();
11205        let stored_outcome = runtime.mutate_kv(stored.clone()).unwrap();
11206        let conflict =
11207            KvCommandV1::put("kv-stored", b"stored-key".to_vec(), b"conflict".to_vec()).unwrap();
11208        let commit = runtime.lock_commit().unwrap();
11209        let start = Arc::new(Barrier::new(3));
11210        let retry_worker = {
11211            let runtime = Arc::clone(&runtime);
11212            let start = Arc::clone(&start);
11213            std::thread::spawn(move || {
11214                start.wait();
11215                runtime.mutate_kv_batch(vec![stored, conflict])
11216            })
11217        };
11218        let new_worker = {
11219            let runtime = Arc::clone(&runtime);
11220            let start = Arc::clone(&start);
11221            std::thread::spawn(move || {
11222                start.wait();
11223                runtime.mutate_kv_batch(vec![
11224                    KvCommandV1::put("kv-new-1", b"new-1".to_vec(), b"one".to_vec()).unwrap(),
11225                    KvCommandV1::put("kv-new-2", b"new-2".to_vec(), b"two".to_vec()).unwrap(),
11226                ])
11227            })
11228        };
11229        start.wait();
11230        runtime
11231            .kv_group_commit
11232            .wait_for_pending_calls(2, Duration::from_secs(5));
11233        drop(commit);
11234
11235        let retry_results = retry_worker.join().unwrap().unwrap();
11236        let new_results = new_worker.join().unwrap().unwrap();
11237
11238        assert_eq!(
11239            retry_results[0].as_ref().unwrap().applied_index(),
11240            stored_outcome.applied_index()
11241        );
11242        assert!(matches!(
11243            retry_results[1],
11244            Err(NodeError::InvalidRequest(_))
11245        ));
11246        let new_anchors = new_results
11247            .iter()
11248            .map(|result| {
11249                let outcome = result.as_ref().unwrap();
11250                (outcome.applied_index(), outcome.hash())
11251            })
11252            .collect::<std::collections::HashSet<_>>();
11253        assert_eq!(new_anchors.len(), 1);
11254        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11255    }
11256
11257    #[cfg(feature = "kv")]
11258    #[test]
11259    fn kv_group_commit_releases_pending_byte_budget_after_drain_and_failure() {
11260        let queue = super::KvGroupCommitQueue::new();
11261        let cancelled = AtomicBool::new(false);
11262        let member = |id: usize, bytes: usize| super::RuntimeBatchMember {
11263            #[cfg(feature = "sql")]
11264            request_id: format!("kv-byte-{id}"),
11265            payload: vec![u8::try_from(id).unwrap_or_default(); bytes],
11266            operation: super::QueuedOperation::Kv(
11267                KvCommandV1::put(
11268                    format!("kv-byte-{id}"),
11269                    format!("key-{id}").into_bytes(),
11270                    b"value".to_vec(),
11271                )
11272                .unwrap(),
11273            ),
11274        };
11275        for id in 0..63 {
11276            queue
11277                .enqueue(vec![member(id, MAX_COMMAND_BYTES)], &cancelled)
11278                .unwrap();
11279        }
11280
11281        let overflow = match queue.enqueue(vec![member(63, MAX_COMMAND_BYTES * 2)], &cancelled) {
11282            Ok(_) => panic!("pending KV byte budget must reject oversized aggregate work"),
11283            Err(error) => error,
11284        };
11285        assert!(matches!(overflow, NodeError::ResourceExhausted(_)));
11286
11287        let drained = queue.drain_next_group().unwrap();
11288        assert_eq!(drained.len(), 63);
11289        let released = queue
11290            .enqueue(vec![member(64, MAX_COMMAND_BYTES)], &cancelled)
11291            .unwrap()
11292            .0;
11293        queue.fail_pending(NodeError::Unavailable("test failure".into()));
11294        assert!(matches!(
11295            released.wait(&cancelled),
11296            Err(NodeError::Unavailable(_))
11297        ));
11298        let state = queue
11299            .state
11300            .lock()
11301            .unwrap_or_else(std::sync::PoisonError::into_inner);
11302        assert!(state.pending.is_empty());
11303        assert_eq!(state.pending_encoded_bytes, 0);
11304        assert!(!state.leader_active);
11305    }
11306
11307    #[cfg(feature = "kv")]
11308    #[test]
11309    fn kv_group_commit_window_restarts_after_staggered_enqueue() {
11310        let queue = Arc::new(super::KvGroupCommitQueue::new());
11311        let cancelled = AtomicBool::new(false);
11312        let member = |id: usize| super::RuntimeBatchMember {
11313            #[cfg(feature = "sql")]
11314            request_id: format!("kv-debounce-{id}"),
11315            payload: vec![u8::try_from(id).unwrap_or_default()],
11316            operation: super::QueuedOperation::Kv(
11317                KvCommandV1::put(
11318                    format!("kv-debounce-{id}"),
11319                    format!("key-{id}").into_bytes(),
11320                    b"value".to_vec(),
11321                )
11322                .unwrap(),
11323            ),
11324        };
11325        queue.enqueue(vec![member(1)], &cancelled).unwrap();
11326        let collector = Arc::clone(&queue);
11327        let (finished, receive) = std::sync::mpsc::channel();
11328        let worker = std::thread::spawn(move || {
11329            let collected = collector.collect_until_full_or_timeout(Duration::from_millis(100));
11330            finished.send(collected).unwrap();
11331        });
11332
11333        std::thread::sleep(Duration::from_millis(75));
11334        queue.enqueue(vec![member(2)], &cancelled).unwrap();
11335        std::thread::sleep(Duration::from_millis(75));
11336        queue.enqueue(vec![member(3)], &cancelled).unwrap();
11337        assert!(receive.recv_timeout(Duration::from_millis(50)).is_err());
11338        assert!(receive.recv_timeout(Duration::from_millis(150)).unwrap());
11339        worker.join().unwrap();
11340        assert_eq!(queue.drain_next_group().unwrap().len(), 3);
11341    }
11342
11343    #[cfg(feature = "kv")]
11344    #[test]
11345    fn kv_group_commit_returns_committed_group_when_cancelled_after_execution() {
11346        let (_dir, mut runtime) = kv_test_runtime();
11347        runtime.kv_group_commit_after_execute_hook = Some(Arc::new(NodeRuntime::cancel_operations));
11348        let runtime = Arc::new(runtime);
11349        let commit = runtime.lock_commit().unwrap();
11350        let start = Arc::new(Barrier::new(3));
11351        let workers = (0..2)
11352            .map(|call| {
11353                let runtime = Arc::clone(&runtime);
11354                let start = Arc::clone(&start);
11355                std::thread::spawn(move || {
11356                    let commands = (0..64)
11357                        .map(|member| {
11358                            let id = call * 64 + member;
11359                            KvCommandV1::put(
11360                                format!("kv-cancel-after-{id}"),
11361                                format!("key-{id}").into_bytes(),
11362                                b"value".to_vec(),
11363                            )
11364                            .unwrap()
11365                        })
11366                        .collect();
11367                    start.wait();
11368                    runtime.mutate_kv_batch(commands)
11369                })
11370            })
11371            .collect::<Vec<_>>();
11372        start.wait();
11373        runtime
11374            .kv_group_commit
11375            .wait_for_pending_calls(2, Duration::from_secs(5));
11376        drop(commit);
11377
11378        let results = workers
11379            .into_iter()
11380            .flat_map(|worker| worker.join().unwrap().unwrap())
11381            .collect::<Vec<_>>();
11382
11383        assert_eq!(results.len(), 128);
11384        let anchors = results
11385            .iter()
11386            .map(|result| {
11387                let outcome = result.as_ref().unwrap();
11388                (outcome.applied_index(), outcome.hash())
11389            })
11390            .collect::<std::collections::HashSet<_>>();
11391        assert_eq!(anchors.len(), 1);
11392        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11393        assert!(runtime.operation_cancelled.load(Ordering::Acquire));
11394    }
11395
11396    #[cfg(feature = "kv")]
11397    #[test]
11398    fn kv_largest_fitting_prefix_is_exact_for_large_grouped_batch() {
11399        let commands = (0..256)
11400            .map(|id| {
11401                KvCommandV1::put(
11402                    format!("kv-prefix-{id:04}"),
11403                    format!("key-{id:04}").into_bytes(),
11404                    vec![b'x'; 4 * 1024],
11405                )
11406                .unwrap()
11407            })
11408            .collect::<Vec<_>>();
11409        assert!(super::encode_replicated_kv_batch(&commands).unwrap().len() > MAX_COMMAND_BYTES);
11410        let expected = (2..commands.len())
11411            .filter(|count| {
11412                super::encode_replicated_kv_batch(&commands[..*count])
11413                    .unwrap()
11414                    .len()
11415                    <= MAX_COMMAND_BYTES
11416            })
11417            .max()
11418            .unwrap();
11419
11420        let (count, payload) = super::largest_fitting_kv_batch_prefix(&commands).unwrap();
11421
11422        assert_eq!(count, expected);
11423        assert!(payload.len() <= MAX_COMMAND_BYTES);
11424        assert!(
11425            super::encode_replicated_kv_batch(&commands[..count + 1])
11426                .unwrap()
11427                .len()
11428                > MAX_COMMAND_BYTES
11429        );
11430    }
11431
11432    #[cfg(feature = "kv")]
11433    #[test]
11434    fn kv_group_commit_large_batch_uses_largest_fitting_fifo_sub_batches() {
11435        let (_dir, runtime) = kv_test_runtime();
11436        let runtime = Arc::new(runtime);
11437        let calls = (0..4)
11438            .map(|call| {
11439                (0..64)
11440                    .map(|member| {
11441                        let id = call * 64 + member;
11442                        KvCommandV1::put(
11443                            format!("kv-large-{id:04}"),
11444                            format!("key-{id:04}").into_bytes(),
11445                            vec![b'x'; 4 * 1024],
11446                        )
11447                        .unwrap()
11448                    })
11449                    .collect::<Vec<_>>()
11450            })
11451            .collect::<Vec<_>>();
11452        let flattened = calls.iter().flatten().cloned().collect::<Vec<_>>();
11453        let (largest_prefix, _) = super::largest_fitting_kv_batch_prefix(&flattened).unwrap();
11454        let expected_entries = flattened.len().div_ceil(largest_prefix);
11455        let commit = runtime.lock_commit().unwrap();
11456        let start = Arc::new(Barrier::new(5));
11457        let workers = calls
11458            .into_iter()
11459            .map(|commands| {
11460                let runtime = Arc::clone(&runtime);
11461                let start = Arc::clone(&start);
11462                std::thread::spawn(move || {
11463                    start.wait();
11464                    runtime.mutate_kv_batch(commands)
11465                })
11466            })
11467            .collect::<Vec<_>>();
11468        start.wait();
11469        runtime
11470            .kv_group_commit
11471            .wait_for_pending_calls(4, Duration::from_secs(5));
11472        drop(commit);
11473
11474        let mut counts = std::collections::BTreeMap::new();
11475        for result in workers
11476            .into_iter()
11477            .flat_map(|worker| worker.join().unwrap().unwrap())
11478        {
11479            *counts
11480                .entry(result.unwrap().applied_index())
11481                .or_insert(0_usize) += 1;
11482        }
11483
11484        assert_eq!(counts.len(), expected_entries);
11485        let counts = counts.into_values().collect::<Vec<_>>();
11486        assert!(counts[..counts.len() - 1]
11487            .iter()
11488            .all(|count| *count == largest_prefix));
11489        assert_eq!(counts.iter().sum::<usize>(), 256);
11490        assert_eq!(
11491            runtime.log_store().last_index().unwrap(),
11492            Some(u64::try_from(expected_entries).unwrap())
11493        );
11494        for index in 1..=u64::try_from(expected_entries).unwrap() {
11495            assert!(runtime
11496                .log_store()
11497                .read(index)
11498                .unwrap()
11499                .is_some_and(|entry| entry.payload.len() <= MAX_COMMAND_BYTES));
11500        }
11501    }
11502
11503    #[cfg(feature = "kv")]
11504    #[test]
11505    fn kv_group_commit_leader_panic_wakes_active_and_pending_calls_with_same_fatal() {
11506        let (_dir, mut runtime) = kv_test_runtime();
11507        runtime.kv_group_commit_before_execute_hook =
11508            Some(Arc::new(|| panic!("injected KV group leader panic")));
11509        let runtime = Arc::new(runtime);
11510        let commit = runtime.lock_commit().unwrap();
11511        let mut workers = Vec::new();
11512        for call in 0..17 {
11513            let worker_runtime = Arc::clone(&runtime);
11514            workers.push(std::thread::spawn(move || {
11515                worker_runtime.mutate_kv_batch(
11516                    (0..64)
11517                        .map(|member| {
11518                            let id = call * 64 + member;
11519                            KvCommandV1::put(
11520                                format!("kv-panic-{id}"),
11521                                format!("key-{id}").into_bytes(),
11522                                b"value".to_vec(),
11523                            )
11524                            .unwrap()
11525                        })
11526                        .collect(),
11527                )
11528            }));
11529            runtime
11530                .kv_group_commit
11531                .wait_for_pending_calls(call + 1, Duration::from_secs(5));
11532        }
11533        drop(commit);
11534
11535        let errors = workers
11536            .into_iter()
11537            .map(|worker| worker.join().unwrap().unwrap_err())
11538            .collect::<Vec<_>>();
11539        assert!(errors
11540            .iter()
11541            .all(|error| matches!(error, NodeError::Fatal(_))));
11542        assert_eq!(errors[0].to_string(), errors[1].to_string());
11543        assert!(runtime.is_fatal());
11544        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11545    }
11546
11547    #[cfg(feature = "kv")]
11548    #[test]
11549    fn kv_group_commit_reopens_all_four_grouped_calls_at_shared_durable_anchor() {
11550        let (dir, runtime) = kv_test_runtime();
11551        let runtime = Arc::new(runtime);
11552        let commit = runtime.lock_commit().unwrap();
11553        let start = Arc::new(Barrier::new(5));
11554        let workers = (0..4)
11555            .map(|call| {
11556                let runtime = Arc::clone(&runtime);
11557                let start = Arc::clone(&start);
11558                std::thread::spawn(move || {
11559                    let commands = (0..64)
11560                        .map(|member| {
11561                            let id = call * 64 + member;
11562                            KvCommandV1::put(
11563                                format!("kv-reopen-{id}"),
11564                                format!("key-{id:04}").into_bytes(),
11565                                vec![u8::try_from(call).unwrap(); 128],
11566                            )
11567                            .unwrap()
11568                        })
11569                        .collect();
11570                    start.wait();
11571                    runtime.mutate_kv_batch(commands)
11572                })
11573            })
11574            .collect::<Vec<_>>();
11575        start.wait();
11576        runtime
11577            .kv_group_commit
11578            .wait_for_pending_calls(4, Duration::from_secs(5));
11579        drop(commit);
11580
11581        let results = workers
11582            .into_iter()
11583            .flat_map(|worker| worker.join().unwrap().unwrap())
11584            .collect::<Vec<_>>();
11585        let anchors = results
11586            .iter()
11587            .map(|result| {
11588                let outcome = result.as_ref().unwrap();
11589                (outcome.applied_index(), outcome.hash())
11590            })
11591            .collect::<std::collections::HashSet<_>>();
11592        let [(applied_index, applied_hash)] = anchors.into_iter().collect::<Vec<_>>()[..] else {
11593            panic!("four grouped calls must share one durable anchor");
11594        };
11595        assert_eq!(
11596            runtime.log_store().last_index().unwrap(),
11597            Some(applied_index)
11598        );
11599        let config = runtime.config().clone();
11600        drop(runtime);
11601
11602        let consensus = Arc::new(
11603            ThreeNodeConsensus::from_recovered_tip(
11604                config.cluster_id().to_owned(),
11605                config.node_id().to_owned(),
11606                config.epoch(),
11607                config.config_id(),
11608                [
11609                    dir.path().join("recorders/n1"),
11610                    dir.path().join("recorders/n2"),
11611                    dir.path().join("recorders/n3"),
11612                ],
11613                applied_index + 1,
11614                applied_hash,
11615            )
11616            .unwrap(),
11617        );
11618        let reopened = NodeRuntime::open(config, consensus, &[]).unwrap();
11619
11620        assert_eq!(reopened.applied_index().unwrap(), applied_index);
11621        assert_eq!(reopened.applied_hash().unwrap(), applied_hash);
11622        assert_eq!(reopened.log_store().last_index().unwrap(), Some(1));
11623        for id in 0..256 {
11624            let response = reopened
11625                .get_kv(format!("key-{id:04}").as_bytes(), ReadConsistency::Local)
11626                .unwrap();
11627            assert_eq!(
11628                response.value,
11629                Some(vec![u8::try_from(id / 64).unwrap(); 128])
11630            );
11631            assert_eq!(response.applied_index, applied_index);
11632            assert_eq!(response.hash, applied_hash);
11633        }
11634    }
11635
11636    #[test]
11637    fn sql_batch_preflight_rejects_entire_vector_without_growing_log() {
11638        let (_dir, runtime) = sql_test_runtime();
11639        let valid = SqlCommand {
11640            request_id: "valid".into(),
11641            statements: vec![SqlStatement {
11642                sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY)".into(),
11643                parameters: vec![],
11644            }],
11645        };
11646        let invalid = SqlCommand {
11647            request_id: String::new(),
11648            statements: valid.statements.clone(),
11649        };
11650
11651        let error = runtime.execute_sql_batch(vec![valid, invalid]).unwrap_err();
11652
11653        assert!(matches!(error, NodeError::InvalidRequest(_)));
11654        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11655    }
11656
11657    #[test]
11658    fn sql_batch_rejects_aggregate_encoded_input_over_command_cap_before_io() {
11659        let (_dir, runtime) = sql_test_runtime();
11660        let command = |request_id: &str, fill: char| SqlCommand {
11661            request_id: request_id.into(),
11662            statements: vec![SqlStatement {
11663                sql: "SELECT ?1".into(),
11664                parameters: vec![SqlValue::Text(
11665                    std::iter::repeat_n(fill, MAX_COMMAND_BYTES / 2).collect(),
11666                )],
11667            }],
11668        };
11669
11670        let error = runtime
11671            .execute_sql_batch(vec![
11672                command("aggregate-a", 'a'),
11673                command("aggregate-b", 'b'),
11674            ])
11675            .unwrap_err();
11676
11677        assert!(matches!(error, NodeError::ResourceExhausted(_)));
11678        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11679        assert!(runtime
11680            .sql_group_commit
11681            .state
11682            .lock()
11683            .unwrap()
11684            .pending
11685            .is_empty());
11686    }
11687
11688    #[test]
11689    fn sql_write_profiling_records_nothing_when_observer_is_not_installed() {
11690        let profiler = SqlWriteProfiler::new(8);
11691        let (_dir, runtime) = sql_test_runtime();
11692
11693        runtime
11694            .execute_sql(SqlCommand {
11695                request_id: "schema".into(),
11696                statements: vec![SqlStatement {
11697                    sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11698                    parameters: vec![],
11699                }],
11700            })
11701            .unwrap();
11702
11703        assert!(runtime.config().sql_write_profiler().is_none());
11704        assert!(profiler.snapshot().samples.is_empty());
11705    }
11706
11707    #[test]
11708    fn sql_write_profiling_records_one_consistent_sample_for_one_physical_batch() {
11709        let profiler = SqlWriteProfiler::new(8);
11710        let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11711        runtime
11712            .execute_sql(SqlCommand {
11713                request_id: "schema".into(),
11714                statements: vec![SqlStatement {
11715                    sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11716                    parameters: vec![],
11717                }],
11718            })
11719            .unwrap();
11720        profiler.drain();
11721
11722        let commands = (1..=3)
11723            .map(|id| SqlCommand {
11724                request_id: format!("insert-{id}"),
11725                statements: vec![SqlStatement {
11726                    sql: "INSERT INTO profiled_items(id) VALUES (?1)".into(),
11727                    parameters: vec![SqlValue::Integer(id)],
11728                }],
11729            })
11730            .collect();
11731        let responses = runtime.execute_sql_batch(commands).unwrap();
11732
11733        let snapshot = profiler.snapshot();
11734        assert_eq!(snapshot.dropped_samples, 0);
11735        let [sample] = snapshot.samples.as_slice() else {
11736            panic!("one physical SQL batch must emit one sample: {snapshot:?}");
11737        };
11738        assert_eq!(sample.batch_member_count, 3);
11739        assert_eq!(
11740            sample.total_service_us,
11741            sample
11742                .commit_lock_wait_us
11743                .saturating_add(sample.precheck_classification_us)
11744                .saturating_add(sample.qwal_prepare_us)
11745                .saturating_add(sample.consensus_propose_us)
11746                .saturating_add(sample.local_qlog_mirror_append_us)
11747                .saturating_add(sample.sql_materializer_apply_us)
11748                .saturating_add(sample.response_other_total_us)
11749        );
11750        let (applied_index, applied_hash) = runtime.ensure_materialized_tip().unwrap();
11751        assert!(responses.iter().all(|response| {
11752            response.as_ref().is_ok_and(|response| {
11753                response.applied_index == applied_index && response.hash == applied_hash
11754            })
11755        }));
11756    }
11757
11758    #[test]
11759    fn sql_write_profiling_does_not_fabricate_sample_for_failed_batch() {
11760        let profiler = SqlWriteProfiler::new(8);
11761        let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11762        runtime
11763            .execute_sql(SqlCommand {
11764                request_id: "schema".into(),
11765                statements: vec![SqlStatement {
11766                    sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11767                    parameters: vec![],
11768                }],
11769            })
11770            .unwrap();
11771        runtime
11772            .execute_sql(SqlCommand {
11773                request_id: "first".into(),
11774                statements: vec![SqlStatement {
11775                    sql: "INSERT INTO profiled_items(id) VALUES (1)".into(),
11776                    parameters: vec![],
11777                }],
11778            })
11779            .unwrap();
11780        profiler.drain();
11781        let last_index = runtime.log_store().last_index().unwrap();
11782
11783        let error = runtime
11784            .execute_sql(SqlCommand {
11785                request_id: "duplicate".into(),
11786                statements: vec![SqlStatement {
11787                    sql: "INSERT INTO profiled_items(id) VALUES (1)".into(),
11788                    parameters: vec![],
11789                }],
11790            })
11791            .unwrap_err();
11792
11793        assert!(matches!(error, NodeError::InvalidSqlStatement { .. }));
11794        assert!(profiler.snapshot().samples.is_empty());
11795        assert_eq!(runtime.log_store().last_index().unwrap(), last_index);
11796    }
11797
11798    #[test]
11799    fn sql_group_commit_coalesces_four_waiting_typed_calls_into_one_1024_receipt_qwal() {
11800        let profiler = SqlWriteProfiler::new(8);
11801        let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11802        let runtime = Arc::new(runtime);
11803        runtime
11804            .execute_sql(SqlCommand {
11805                request_id: "group-schema".into(),
11806                statements: vec![SqlStatement {
11807                    sql: "CREATE TABLE grouped_items(id INTEGER PRIMARY KEY)".into(),
11808                    parameters: vec![],
11809                }],
11810            })
11811            .unwrap();
11812        profiler.drain();
11813
11814        let commit = runtime.lock_commit().unwrap();
11815        let start = Arc::new(Barrier::new(5));
11816        let workers = (0..4)
11817            .map(|call| {
11818                let runtime = Arc::clone(&runtime);
11819                let start = Arc::clone(&start);
11820                std::thread::spawn(move || {
11821                    let commands = (0..256)
11822                        .map(|offset| {
11823                            let id = call * 256 + offset;
11824                            SqlCommand {
11825                                request_id: format!("group-{id}"),
11826                                statements: vec![SqlStatement {
11827                                    sql: "INSERT INTO grouped_items(id) VALUES (?1)".into(),
11828                                    parameters: vec![SqlValue::Integer(id)],
11829                                }],
11830                            }
11831                        })
11832                        .collect();
11833                    start.wait();
11834                    runtime.execute_sql_batch(commands).unwrap()
11835                })
11836            })
11837            .collect::<Vec<_>>();
11838        start.wait();
11839        runtime
11840            .sql_group_commit
11841            .wait_for_pending_calls(4, Duration::from_secs(5));
11842        drop(commit);
11843
11844        let results = workers
11845            .into_iter()
11846            .flat_map(|worker| worker.join().unwrap())
11847            .collect::<Vec<_>>();
11848        assert_eq!(results.len(), 1024);
11849        let first = results[0].as_ref().unwrap();
11850        assert!(results.iter().all(|result| {
11851            result.as_ref().is_ok_and(|result| {
11852                result.applied_index == first.applied_index && result.hash == first.hash
11853            })
11854        }));
11855        let entry = runtime
11856            .log_store()
11857            .read(first.applied_index)
11858            .unwrap()
11859            .unwrap();
11860        assert_eq!(
11861            rhiza_sql::decode_qwal_v3(&entry.payload)
11862                .unwrap()
11863                .receipts
11864                .len(),
11865            1024
11866        );
11867        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11868        let snapshot = profiler.snapshot();
11869        let [sample] = snapshot.samples.as_slice() else {
11870            panic!("one grouped physical commit must emit one sample: {snapshot:?}");
11871        };
11872        assert_eq!(sample.batch_member_count, 1024);
11873    }
11874
11875    #[test]
11876    fn sql_group_commit_keeps_fifth_whole_call_for_the_next_physical_group() {
11877        let (_dir, runtime) = sql_test_runtime();
11878        let runtime = Arc::new(runtime);
11879        runtime
11880            .execute_sql(SqlCommand {
11881                request_id: "next-group-schema".into(),
11882                statements: vec![SqlStatement {
11883                    sql: "CREATE TABLE next_group_items(id INTEGER PRIMARY KEY)".into(),
11884                    parameters: vec![],
11885                }],
11886            })
11887            .unwrap();
11888
11889        let commit = runtime.lock_commit().unwrap();
11890        let mut workers = Vec::new();
11891        for call in 0..5 {
11892            let worker_runtime = Arc::clone(&runtime);
11893            workers.push(std::thread::spawn(move || {
11894                worker_runtime
11895                    .execute_sql_batch(
11896                        (0..256)
11897                            .map(|offset| {
11898                                let id = call * 256 + offset;
11899                                SqlCommand {
11900                                    request_id: format!("next-group-{id}"),
11901                                    statements: vec![SqlStatement {
11902                                        sql: "INSERT INTO next_group_items(id) VALUES (?1)".into(),
11903                                        parameters: vec![SqlValue::Integer(id)],
11904                                    }],
11905                                }
11906                            })
11907                            .collect(),
11908                    )
11909                    .unwrap()
11910            }));
11911            runtime
11912                .sql_group_commit
11913                .wait_for_pending_calls(call as usize + 1, Duration::from_secs(5));
11914        }
11915        drop(commit);
11916
11917        let calls = workers
11918            .into_iter()
11919            .map(|worker| worker.join().unwrap())
11920            .collect::<Vec<_>>();
11921        for call in &calls[..4] {
11922            assert!(call
11923                .iter()
11924                .all(|result| result.as_ref().unwrap().applied_index == 2));
11925        }
11926        assert!(calls[4]
11927            .iter()
11928            .all(|result| result.as_ref().unwrap().applied_index == 3));
11929        assert_eq!(
11930            rhiza_sql::decode_qwal_v3(&runtime.log_store().read(2).unwrap().unwrap().payload)
11931                .unwrap()
11932                .receipts
11933                .len(),
11934            1024
11935        );
11936        assert_eq!(
11937            rhiza_sql::decode_qwal_v3(&runtime.log_store().read(3).unwrap().unwrap().payload)
11938                .unwrap()
11939                .receipts
11940                .len(),
11941            256
11942        );
11943    }
11944
11945    #[test]
11946    fn sql_group_commit_preserves_fifo_call_offsets_for_retries_conflicts_aliases_and_failures() {
11947        let (_dir, runtime) = sql_test_runtime();
11948        let runtime = Arc::new(runtime);
11949        runtime
11950            .execute_sql(SqlCommand {
11951                request_id: "fifo-schema".into(),
11952                statements: vec![SqlStatement {
11953                    sql: "CREATE TABLE fifo_items(id INTEGER PRIMARY KEY, value TEXT UNIQUE)"
11954                        .into(),
11955                    parameters: vec![],
11956                }],
11957            })
11958            .unwrap();
11959        let stored_command = SqlCommand {
11960            request_id: "fifo-stored".into(),
11961            statements: vec![SqlStatement {
11962                sql: "INSERT INTO fifo_items(id, value) VALUES (1, 'stored')".into(),
11963                parameters: vec![],
11964            }],
11965        };
11966        let stored = runtime.execute_sql(stored_command.clone()).unwrap();
11967        let valid_alias = SqlCommand {
11968            request_id: "fifo-alias".into(),
11969            statements: vec![SqlStatement {
11970                sql: "INSERT INTO fifo_items(id, value) VALUES (2, 'alias')".into(),
11971                parameters: vec![],
11972            }],
11973        };
11974        let conflict = SqlCommand {
11975            request_id: stored_command.request_id.clone(),
11976            statements: vec![SqlStatement {
11977                sql: "INSERT INTO fifo_items(id, value) VALUES (3, 'conflict')".into(),
11978                parameters: vec![],
11979            }],
11980        };
11981        let failed = SqlCommand {
11982            request_id: "fifo-failed".into(),
11983            statements: vec![SqlStatement {
11984                sql: "INSERT INTO fifo_items(id, value) VALUES (4, 'stored')".into(),
11985                parameters: vec![],
11986            }],
11987        };
11988        let valid = SqlCommand {
11989            request_id: "fifo-valid".into(),
11990            statements: vec![SqlStatement {
11991                sql: "INSERT INTO fifo_items(id, value) VALUES (5, 'valid')".into(),
11992                parameters: vec![],
11993            }],
11994        };
11995
11996        let commit = runtime.lock_commit().unwrap();
11997        let first_runtime = Arc::clone(&runtime);
11998        let first = std::thread::spawn(move || {
11999            first_runtime
12000                .execute_sql_batch(vec![
12001                    stored_command,
12002                    conflict,
12003                    valid_alias.clone(),
12004                    valid_alias,
12005                ])
12006                .unwrap()
12007        });
12008        runtime
12009            .sql_group_commit
12010            .wait_for_pending_calls(1, Duration::from_secs(5));
12011        let second_runtime = Arc::clone(&runtime);
12012        let second = std::thread::spawn(move || {
12013            second_runtime
12014                .execute_sql_batch(vec![failed, valid])
12015                .unwrap()
12016        });
12017        runtime
12018            .sql_group_commit
12019            .wait_for_pending_calls(2, Duration::from_secs(5));
12020        drop(commit);
12021
12022        let first = first.join().unwrap();
12023        let second = second.join().unwrap();
12024        assert_eq!(
12025            first[0].as_ref().unwrap().applied_index,
12026            stored.applied_index
12027        );
12028        assert!(matches!(first[1], Err(NodeError::RequestConflict(_))));
12029        assert_eq!(first[2], first[3]);
12030        assert_eq!(first[2].as_ref().unwrap().applied_index, 3);
12031        assert!(matches!(
12032            second[0],
12033            Err(NodeError::InvalidSqlStatement { .. })
12034        ));
12035        assert_eq!(second[1].as_ref().unwrap().applied_index, 3);
12036    }
12037
12038    #[test]
12039    fn sql_group_commit_rejects_overload_before_enqueue_without_orphaning_the_leader() {
12040        let (_dir, runtime) = sql_test_runtime_configured(None, Some(1));
12041        let runtime = Arc::new(runtime);
12042        runtime
12043            .execute_sql(SqlCommand {
12044                request_id: "overload-schema".into(),
12045                statements: vec![SqlStatement {
12046                    sql: "CREATE TABLE overload_items(id INTEGER PRIMARY KEY)".into(),
12047                    parameters: vec![],
12048                }],
12049            })
12050            .unwrap();
12051        let command = |request_id: &str, id| SqlCommand {
12052            request_id: request_id.into(),
12053            statements: vec![SqlStatement {
12054                sql: "INSERT INTO overload_items(id) VALUES (?1)".into(),
12055                parameters: vec![SqlValue::Integer(id)],
12056            }],
12057        };
12058
12059        let commit = runtime.lock_commit().unwrap();
12060        let leader_runtime = Arc::clone(&runtime);
12061        let leader = std::thread::spawn(move || {
12062            leader_runtime.execute_sql_batch(vec![command("overload-first", 1)])
12063        });
12064        runtime
12065            .sql_group_commit
12066            .wait_for_pending_calls(1, Duration::from_secs(5));
12067        let overload = runtime
12068            .execute_sql_batch(vec![command("overload-second", 2)])
12069            .unwrap_err();
12070        assert!(matches!(overload, NodeError::ResourceExhausted(_)));
12071        drop(commit);
12072        assert!(leader.join().unwrap().unwrap()[0].is_ok());
12073        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
12074    }
12075
12076    #[test]
12077    fn sql_group_commit_bounds_pending_bytes_and_releases_reservations() {
12078        let queue = super::SqlGroupCommitQueue::new(super::MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY);
12079        let cancelled = AtomicBool::new(false);
12080        let member = |id: usize| super::RuntimeBatchMember {
12081            request_id: format!("queued-{id}"),
12082            payload: vec![u8::try_from(id).unwrap_or_default(); MAX_COMMAND_BYTES],
12083            operation: super::QueuedOperation::Sql(SqlCommand {
12084                request_id: format!("queued-{id}"),
12085                statements: vec![SqlStatement {
12086                    sql: "SELECT 1".into(),
12087                    parameters: vec![],
12088                }],
12089            }),
12090        };
12091        let mut queued = Vec::new();
12092        for id in 0..super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY {
12093            queued.push(queue.enqueue(vec![member(id)], &cancelled).unwrap().0);
12094        }
12095
12096        let overflow = match queue.enqueue(
12097            vec![member(super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY)],
12098            &cancelled,
12099        ) {
12100            Ok(_) => panic!("pending byte budget must reject one more full command"),
12101            Err(error) => error,
12102        };
12103        assert!(matches!(overflow, NodeError::ResourceExhausted(_)));
12104
12105        let drained = queue.drain_next_group().unwrap();
12106        assert_eq!(drained.len(), 4);
12107        let released = queue
12108            .enqueue(
12109                vec![member(super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY + 1)],
12110                &cancelled,
12111            )
12112            .unwrap()
12113            .0;
12114
12115        queue.fail_pending(NodeError::Unavailable("test failure".into()));
12116        for job in queued.into_iter().skip(drained.len()) {
12117            assert!(matches!(
12118                job.wait(&cancelled),
12119                Err(NodeError::Unavailable(_))
12120            ));
12121        }
12122        assert!(matches!(
12123            released.wait(&cancelled),
12124            Err(NodeError::Unavailable(_))
12125        ));
12126        let state = queue
12127            .state
12128            .lock()
12129            .unwrap_or_else(std::sync::PoisonError::into_inner);
12130        assert!(state.pending.is_empty());
12131        assert_eq!(state.pending_encoded_bytes, 0);
12132        assert!(!state.leader_active);
12133    }
12134
12135    #[test]
12136    fn sql_group_commit_window_restarts_after_staggered_enqueue() {
12137        let queue = Arc::new(super::SqlGroupCommitQueue::new(
12138            super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY,
12139        ));
12140        let cancelled = AtomicBool::new(false);
12141        let member = |id: usize| super::RuntimeBatchMember {
12142            request_id: format!("sql-debounce-{id}"),
12143            payload: vec![u8::try_from(id).unwrap_or_default()],
12144            operation: super::QueuedOperation::Sql(SqlCommand {
12145                request_id: format!("sql-debounce-{id}"),
12146                statements: vec![SqlStatement {
12147                    sql: "SELECT 1".into(),
12148                    parameters: vec![],
12149                }],
12150            }),
12151        };
12152        queue.enqueue(vec![member(1)], &cancelled).unwrap();
12153        let collector = Arc::clone(&queue);
12154        let (finished, receive) = std::sync::mpsc::channel();
12155        let worker = std::thread::spawn(move || {
12156            let collected = collector.collect_until_full_or_timeout(Duration::from_millis(100));
12157            finished.send(collected).unwrap();
12158        });
12159
12160        std::thread::sleep(Duration::from_millis(75));
12161        queue.enqueue(vec![member(2)], &cancelled).unwrap();
12162        std::thread::sleep(Duration::from_millis(75));
12163        queue.enqueue(vec![member(3)], &cancelled).unwrap();
12164        assert!(receive.recv_timeout(Duration::from_millis(50)).is_err());
12165        assert!(receive.recv_timeout(Duration::from_millis(150)).unwrap());
12166        worker.join().unwrap();
12167        assert_eq!(queue.drain_next_group().unwrap().len(), 3);
12168    }
12169
12170    #[test]
12171    fn sql_group_commit_leader_panic_wakes_every_queued_call_with_the_same_fatal_error() {
12172        let (_dir, mut runtime) = sql_test_runtime();
12173        runtime
12174            .execute_sql(SqlCommand {
12175                request_id: "panic-schema".into(),
12176                statements: vec![SqlStatement {
12177                    sql: "CREATE TABLE panic_items(id INTEGER PRIMARY KEY)".into(),
12178                    parameters: vec![],
12179                }],
12180            })
12181            .unwrap();
12182        runtime.sql_group_commit_before_execute_hook =
12183            Some(Arc::new(|| panic!("injected SQL group leader panic")));
12184        let runtime = Arc::new(runtime);
12185        let commit = runtime.lock_commit().unwrap();
12186        let mut workers = Vec::new();
12187        for id in 1..=2 {
12188            let worker_runtime = Arc::clone(&runtime);
12189            workers.push(std::thread::spawn(move || {
12190                worker_runtime.execute_sql_batch(vec![SqlCommand {
12191                    request_id: format!("panic-{id}"),
12192                    statements: vec![SqlStatement {
12193                        sql: "INSERT INTO panic_items(id) VALUES (?1)".into(),
12194                        parameters: vec![SqlValue::Integer(id)],
12195                    }],
12196                }])
12197            }));
12198            runtime
12199                .sql_group_commit
12200                .wait_for_pending_calls(id as usize, Duration::from_secs(5));
12201        }
12202        drop(commit);
12203
12204        let errors = workers
12205            .into_iter()
12206            .map(|worker| worker.join().unwrap().unwrap_err())
12207            .collect::<Vec<_>>();
12208        assert!(errors
12209            .iter()
12210            .all(|error| matches!(error, NodeError::Fatal(_))));
12211        assert_eq!(errors[0].to_string(), errors[1].to_string());
12212        assert!(runtime.is_fatal());
12213    }
12214
12215    #[test]
12216    fn sql_group_commit_shutdown_wakes_queued_calls_with_the_same_unavailable_error() {
12217        let (_dir, runtime) = sql_test_runtime();
12218        let runtime = Arc::new(runtime);
12219        runtime
12220            .execute_sql(SqlCommand {
12221                request_id: "shutdown-schema".into(),
12222                statements: vec![SqlStatement {
12223                    sql: "CREATE TABLE shutdown_items(id INTEGER PRIMARY KEY)".into(),
12224                    parameters: vec![],
12225                }],
12226            })
12227            .unwrap();
12228        let command = |id| SqlCommand {
12229            request_id: format!("shutdown-{id}"),
12230            statements: vec![SqlStatement {
12231                sql: "INSERT INTO shutdown_items(id) VALUES (?1)".into(),
12232                parameters: vec![SqlValue::Integer(id)],
12233            }],
12234        };
12235
12236        let commit = runtime.lock_commit().unwrap();
12237        let leader_runtime = Arc::clone(&runtime);
12238        let leader = std::thread::spawn(move || leader_runtime.execute_sql_batch(vec![command(1)]));
12239        runtime
12240            .sql_group_commit
12241            .wait_for_pending_calls(1, Duration::from_secs(5));
12242        let follower_runtime = Arc::clone(&runtime);
12243        let follower =
12244            std::thread::spawn(move || follower_runtime.execute_sql_batch(vec![command(2)]));
12245        runtime
12246            .sql_group_commit
12247            .wait_for_pending_calls(2, Duration::from_secs(5));
12248
12249        runtime.cancel_operations();
12250        let follower_error = follower.join().unwrap().unwrap_err();
12251        drop(commit);
12252        let leader_error = leader.join().unwrap().unwrap_err();
12253
12254        assert!(matches!(leader_error, NodeError::Unavailable(_)));
12255        assert_eq!(leader_error.to_string(), follower_error.to_string());
12256        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
12257    }
12258
12259    #[test]
12260    fn sql_group_commit_reprepares_combined_calls_after_a_foreign_slot_winner() {
12261        let (_dir, runtime) = sql_test_runtime();
12262        let runtime = Arc::new(runtime);
12263        let schema = runtime
12264            .execute_sql(SqlCommand {
12265                request_id: "group-winner-schema".into(),
12266                statements: vec![SqlStatement {
12267                    sql: "CREATE TABLE group_winner(id INTEGER PRIMARY KEY)".into(),
12268                    parameters: vec![],
12269                }],
12270            })
12271            .unwrap();
12272        let winner = runtime
12273            .consensus()
12274            .propose_at(
12275                2,
12276                schema.hash,
12277                Command::new(CommandKind::ReadBarrier, Vec::new()),
12278            )
12279            .unwrap();
12280
12281        let commit = runtime.lock_commit().unwrap();
12282        let mut workers = Vec::new();
12283        for id in 1..=2 {
12284            let worker_runtime = Arc::clone(&runtime);
12285            workers.push(std::thread::spawn(move || {
12286                worker_runtime
12287                    .execute_sql_batch(vec![SqlCommand {
12288                        request_id: format!("group-winner-{id}"),
12289                        statements: vec![SqlStatement {
12290                            sql: "INSERT INTO group_winner(id) VALUES (?1)".into(),
12291                            parameters: vec![SqlValue::Integer(id)],
12292                        }],
12293                    }])
12294                    .unwrap()
12295            }));
12296            runtime
12297                .sql_group_commit
12298                .wait_for_pending_calls(id as usize, Duration::from_secs(5));
12299        }
12300        drop(commit);
12301
12302        let results = workers
12303            .into_iter()
12304            .flat_map(|worker| worker.join().unwrap())
12305            .collect::<Vec<_>>();
12306        assert_eq!(runtime.log_store().read(2).unwrap(), Some(winner));
12307        assert!(results
12308            .iter()
12309            .all(|result| result.as_ref().unwrap().applied_index == 3));
12310        assert_eq!(
12311            results[0].as_ref().unwrap().hash,
12312            results[1].as_ref().unwrap().hash
12313        );
12314    }
12315
12316    #[test]
12317    fn sql_group_commit_all_failed_calls_return_aligned_without_consensus() {
12318        let (_dir, runtime) = sql_test_runtime();
12319        let runtime = Arc::new(runtime);
12320        runtime
12321            .execute_sql(SqlCommand {
12322                request_id: "all-failed-schema".into(),
12323                statements: vec![SqlStatement {
12324                    sql: "CREATE TABLE all_failed(value TEXT UNIQUE)".into(),
12325                    parameters: vec![],
12326                }],
12327            })
12328            .unwrap();
12329        runtime
12330            .execute_sql(SqlCommand {
12331                request_id: "all-failed-existing".into(),
12332                statements: vec![SqlStatement {
12333                    sql: "INSERT INTO all_failed(value) VALUES ('existing')".into(),
12334                    parameters: vec![],
12335                }],
12336            })
12337            .unwrap();
12338
12339        let commit = runtime.lock_commit().unwrap();
12340        let mut workers = Vec::new();
12341        for id in 1..=2 {
12342            let worker_runtime = Arc::clone(&runtime);
12343            workers.push(std::thread::spawn(move || {
12344                worker_runtime
12345                    .execute_sql_batch(vec![SqlCommand {
12346                        request_id: format!("all-failed-{id}"),
12347                        statements: vec![SqlStatement {
12348                            sql: "INSERT INTO all_failed(value) VALUES ('existing')".into(),
12349                            parameters: vec![],
12350                        }],
12351                    }])
12352                    .unwrap()
12353            }));
12354            runtime
12355                .sql_group_commit
12356                .wait_for_pending_calls(id as usize, Duration::from_secs(5));
12357        }
12358        drop(commit);
12359
12360        for worker in workers {
12361            let results = worker.join().unwrap();
12362            assert_eq!(results.len(), 1);
12363            assert!(matches!(
12364                results[0],
12365                Err(NodeError::InvalidSqlStatement { .. })
12366            ));
12367        }
12368        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
12369    }
12370
12371    #[test]
12372    fn sql_group_commit_lone_call_completes_after_the_bounded_collection_round() {
12373        let (_dir, runtime) = sql_test_runtime();
12374
12375        let result = runtime
12376            .execute_sql_batch(vec![SqlCommand {
12377                request_id: "lone-call".into(),
12378                statements: vec![SqlStatement {
12379                    sql: "CREATE TABLE lone_call(id INTEGER PRIMARY KEY)".into(),
12380                    parameters: vec![],
12381                }],
12382            }])
12383            .unwrap();
12384
12385        assert!(result[0].is_ok());
12386        let queue = runtime
12387            .sql_group_commit
12388            .state
12389            .lock()
12390            .unwrap_or_else(std::sync::PoisonError::into_inner);
12391        assert!(queue.pending.is_empty());
12392        assert!(!queue.leader_active);
12393    }
12394
12395    #[test]
12396    fn key_value_write_commits_qwal_instead_of_raw_put_payload() {
12397        let (_dir, runtime) = sql_test_runtime();
12398
12399        let response = runtime.write("key-value-put", "key", "value").unwrap();
12400
12401        let entry = runtime
12402            .log_store()
12403            .read(response.applied_index)
12404            .unwrap()
12405            .unwrap();
12406        assert!(entry.payload.starts_with(QWAL_V3_MAGIC));
12407        assert!(!entry.payload.starts_with(b"put\t"));
12408        assert_eq!(
12409            runtime.read("key", ReadConsistency::Local).unwrap().value,
12410            Some("value".into())
12411        );
12412    }
12413
12414    #[test]
12415    fn sql_batch_preserves_order_commits_one_qwal_effect_and_retries_exactly() {
12416        let (_dir, runtime) = sql_test_runtime();
12417        runtime
12418            .execute_sql(SqlCommand {
12419                request_id: "schema".into(),
12420                statements: vec![SqlStatement {
12421                    sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
12422                        .into(),
12423                    parameters: vec![],
12424                }],
12425            })
12426            .unwrap();
12427        let commands = (1..=3)
12428            .map(|id| SqlCommand {
12429                request_id: format!("insert-{id}"),
12430                statements: vec![SqlStatement {
12431                    sql: "INSERT INTO batch_items(id, value) VALUES (?1, ?2)".into(),
12432                    parameters: vec![SqlValue::Integer(id), SqlValue::Text(format!("value-{id}"))],
12433                }],
12434            })
12435            .collect::<Vec<_>>();
12436
12437        let first = runtime.execute_sql_batch(commands.clone()).unwrap();
12438        let first_indices = first
12439            .iter()
12440            .map(|result| result.as_ref().unwrap().applied_index)
12441            .collect::<Vec<_>>();
12442        let log_index = runtime.log_store().last_index().unwrap();
12443        let replay = runtime.execute_sql_batch(commands).unwrap();
12444
12445        assert_eq!(first_indices, vec![2, 2, 2]);
12446        assert_eq!(
12447            first
12448                .iter()
12449                .map(|result| result.as_ref().unwrap().hash)
12450                .collect::<std::collections::HashSet<_>>()
12451                .len(),
12452            1
12453        );
12454        for index in 1..=2 {
12455            let entry = runtime.log_store().read(index).unwrap().unwrap();
12456            assert!(entry.payload.starts_with(QWAL_V3_MAGIC));
12457        }
12458        assert_eq!(
12459            replay
12460                .iter()
12461                .map(|result| result.as_ref().unwrap().applied_index)
12462                .collect::<Vec<_>>(),
12463            first_indices
12464        );
12465        assert_eq!(runtime.log_store().last_index().unwrap(), log_index);
12466    }
12467
12468    #[test]
12469    fn sql_effect_over_qlog_limit_is_resource_exhausted() {
12470        let (_dir, runtime) = sql_test_runtime();
12471        runtime
12472            .execute_sql(SqlCommand {
12473                request_id: "schema".into(),
12474                statements: vec![SqlStatement {
12475                    sql: "CREATE TABLE large_effect(value BLOB NOT NULL)".into(),
12476                    parameters: vec![],
12477                }],
12478            })
12479            .unwrap();
12480
12481        let error = runtime
12482            .execute_sql(SqlCommand {
12483                request_id: "large-effect".into(),
12484                statements: vec![SqlStatement {
12485                    sql: "INSERT INTO large_effect(value) VALUES (randomblob(700000))".into(),
12486                    parameters: vec![],
12487                }],
12488            })
12489            .unwrap_err();
12490
12491        assert!(matches!(error, NodeError::ResourceExhausted(_)));
12492        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
12493    }
12494
12495    #[test]
12496    fn sql_batch_isolates_request_conflict_from_unrelated_member() {
12497        let (_dir, runtime) = sql_test_runtime();
12498        runtime
12499            .execute_sql(SqlCommand {
12500                request_id: "schema".into(),
12501                statements: vec![SqlStatement {
12502                    sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
12503                        .into(),
12504                    parameters: vec![],
12505                }],
12506            })
12507            .unwrap();
12508        let insert = |request_id: &str, id: i64| SqlCommand {
12509            request_id: request_id.into(),
12510            statements: vec![SqlStatement {
12511                sql: "INSERT INTO batch_items(id, value) VALUES (?1, ?2)".into(),
12512                parameters: vec![SqlValue::Integer(id), SqlValue::Text(format!("value-{id}"))],
12513            }],
12514        };
12515
12516        let results = runtime
12517            .execute_sql_batch(vec![
12518                insert("same", 1),
12519                insert("same", 2),
12520                insert("other", 3),
12521            ])
12522            .unwrap();
12523
12524        assert!(results[0].is_ok());
12525        assert!(matches!(results[1], Err(NodeError::RequestConflict(_))));
12526        let conflict = results[1].as_ref().unwrap_err().classification();
12527        assert_eq!(conflict.code(), "request_conflict");
12528        assert_eq!(conflict.category(), ErrorCategory::Conflict);
12529        assert!(!conflict.retryable());
12530        assert!(results[2].is_ok());
12531        assert_eq!(
12532            results[0].as_ref().unwrap().applied_index,
12533            results[2].as_ref().unwrap().applied_index
12534        );
12535        assert!(runtime.is_ready());
12536    }
12537
12538    #[cfg(feature = "graph")]
12539    #[test]
12540    fn typed_batch_wrong_profile_is_rejected_before_log_attempt() {
12541        let (_dir, runtime) = graph_test_runtime();
12542        let command = SqlCommand {
12543            request_id: "wrong-profile".into(),
12544            statements: vec![SqlStatement {
12545                sql: "CREATE TABLE should_not_exist(id INTEGER PRIMARY KEY)".into(),
12546                parameters: vec![],
12547            }],
12548        };
12549
12550        let error = runtime.execute_sql_batch(vec![command]).unwrap_err();
12551
12552        assert!(matches!(
12553            error,
12554            NodeError::ExecutionProfileMismatch {
12555                expected: ExecutionProfile::Sqlite,
12556                actual: ExecutionProfile::Graph
12557            }
12558        ));
12559        assert_eq!(runtime.log_store().last_index().unwrap(), None);
12560    }
12561
12562    #[cfg(feature = "graph")]
12563    #[test]
12564    fn graph_query_timeout_returns_503_without_latching_readiness() {
12565        let (_dir, runtime) = graph_test_runtime();
12566        let graph = runtime.graph_materializer().unwrap();
12567        let graph_error = graph
12568            .query_read_only(
12569                "UNWIND range(1, 10000) AS x UNWIND range(1, 10000) AS y RETURN sum(x * y) AS total LIMIT 1",
12570                &std::collections::BTreeMap::new(),
12571                1,
12572                1024 * 1024,
12573                1,
12574            )
12575            .unwrap_err();
12576
12577        let response = node_error_response(runtime.map_graph_read_error(graph_error));
12578
12579        assert_eq!(
12580            response.status(),
12581            axum::http::StatusCode::SERVICE_UNAVAILABLE
12582        );
12583        assert!(runtime.is_ready());
12584        assert!(!runtime.is_fatal());
12585    }
12586
12587    #[cfg(feature = "graph")]
12588    #[test]
12589    fn graph_internal_error_returns_500_and_latches_readiness() {
12590        let (_dir, runtime) = graph_test_runtime();
12591
12592        let error =
12593            runtime.map_graph_read_error(rhiza_graph::Error::Ladybug("connection failed".into()));
12594        let response = node_error_response(error);
12595
12596        assert_eq!(
12597            response.status(),
12598            axum::http::StatusCode::INTERNAL_SERVER_ERROR
12599        );
12600        assert!(!runtime.is_ready());
12601        assert!(runtime.is_fatal());
12602    }
12603
12604    fn sql_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12605        sql_test_runtime_configured(None, None)
12606    }
12607
12608    fn sql_test_runtime_with_profiler(
12609        profiler: Option<SqlWriteProfiler>,
12610    ) -> (tempfile::TempDir, NodeRuntime) {
12611        sql_test_runtime_configured(profiler, None)
12612    }
12613
12614    fn sql_test_runtime_configured(
12615        profiler: Option<SqlWriteProfiler>,
12616        queue_capacity: Option<usize>,
12617    ) -> (tempfile::TempDir, NodeRuntime) {
12618        let dir = tempfile::tempdir().unwrap();
12619        let cluster_id = "node-unit-test";
12620        let mut config = NodeConfig::new_embedded(
12621            cluster_id,
12622            "n1",
12623            dir.path().join("node"),
12624            1,
12625            1,
12626            ["n1", "n2", "n3"],
12627        )
12628        .unwrap()
12629        .with_execution_profile(ExecutionProfile::Sqlite)
12630        .unwrap();
12631        if let Some(profiler) = profiler {
12632            config = config.with_sql_write_profiler(profiler);
12633        }
12634        if let Some(queue_capacity) = queue_capacity {
12635            config = config
12636                .with_sql_group_commit_queue_capacity(queue_capacity)
12637                .unwrap();
12638        }
12639        let consensus = Arc::new(
12640            ThreeNodeConsensus::from_recovered_tip(
12641                "rhiza:sql:node-unit-test",
12642                "n1",
12643                1,
12644                1,
12645                [
12646                    dir.path().join("recorders/n1"),
12647                    dir.path().join("recorders/n2"),
12648                    dir.path().join("recorders/n3"),
12649                ],
12650                1,
12651                LogHash::ZERO,
12652            )
12653            .unwrap(),
12654        );
12655        let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12656        (dir, runtime)
12657    }
12658
12659    #[cfg(feature = "graph")]
12660    fn graph_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12661        let dir = tempfile::tempdir().unwrap();
12662        let cluster_id = "node-unit-test";
12663        let config = NodeConfig::new_embedded(
12664            cluster_id,
12665            "n1",
12666            dir.path().join("node"),
12667            1,
12668            1,
12669            ["n1", "n2", "n3"],
12670        )
12671        .unwrap()
12672        .with_execution_profile(ExecutionProfile::Graph)
12673        .unwrap();
12674        let consensus = Arc::new(
12675            ThreeNodeConsensus::from_recovered_tip(
12676                "rhiza:graph:node-unit-test",
12677                "n1",
12678                1,
12679                1,
12680                [
12681                    dir.path().join("recorders/n1"),
12682                    dir.path().join("recorders/n2"),
12683                    dir.path().join("recorders/n3"),
12684                ],
12685                1,
12686                LogHash::ZERO,
12687            )
12688            .unwrap(),
12689        );
12690        let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12691        (dir, runtime)
12692    }
12693
12694    #[cfg(feature = "kv")]
12695    fn kv_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12696        let dir = tempfile::tempdir().unwrap();
12697        let cluster_id = "node-unit-test";
12698        let config = NodeConfig::new_embedded(
12699            cluster_id,
12700            "n1",
12701            dir.path().join("node"),
12702            1,
12703            1,
12704            ["n1", "n2", "n3"],
12705        )
12706        .unwrap()
12707        .with_execution_profile(ExecutionProfile::Kv)
12708        .unwrap();
12709        let consensus = Arc::new(
12710            ThreeNodeConsensus::from_recovered_tip(
12711                "rhiza:kv:node-unit-test",
12712                "n1",
12713                1,
12714                1,
12715                [
12716                    dir.path().join("recorders/n1"),
12717                    dir.path().join("recorders/n2"),
12718                    dir.path().join("recorders/n3"),
12719                ],
12720                1,
12721                LogHash::ZERO,
12722            )
12723            .unwrap(),
12724        );
12725        let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12726        (dir, runtime)
12727    }
12728}
12729
12730#[cfg(feature = "kv")]
12731fn largest_fitting_kv_batch_prefix(commands: &[KvCommandV1]) -> Option<(usize, Vec<u8>)> {
12732    if commands.len() < 3 {
12733        return None;
12734    }
12735    let mut lower = 2_usize;
12736    let mut upper = commands.len() - 1;
12737    let mut largest = None;
12738    while lower <= upper {
12739        let count = lower + (upper - lower) / 2;
12740        let payload = encode_replicated_kv_batch(&commands[..count])
12741            .expect("the validated KV batch prefix remains valid");
12742        if payload.len() <= MAX_COMMAND_BYTES {
12743            largest = Some((count, payload));
12744            lower = count + 1;
12745        } else {
12746            upper = count - 1;
12747        }
12748    }
12749    largest
12750}
12751
12752#[cfg(feature = "graph")]
12753fn validate_typed_batch_len(len: usize) -> Result<(), NodeError> {
12754    if (1..=MAX_WRITE_BATCH_MEMBERS).contains(&len) {
12755        Ok(())
12756    } else {
12757        Err(NodeError::InvalidRequest(format!(
12758            "write batch must contain 1..={MAX_WRITE_BATCH_MEMBERS} commands"
12759        )))
12760    }
12761}
12762
12763#[cfg(feature = "kv")]
12764fn validate_kv_batch_len(len: usize) -> Result<(), NodeError> {
12765    if (1..=MAX_KV_BATCH_MEMBERS).contains(&len) {
12766        Ok(())
12767    } else {
12768        Err(NodeError::InvalidRequest(format!(
12769            "KV write batch must contain 1..={MAX_KV_BATCH_MEMBERS} commands"
12770        )))
12771    }
12772}
12773
12774#[cfg(feature = "sql")]
12775fn validate_sql_batch_len(len: usize) -> Result<(), NodeError> {
12776    if (1..=MAX_TYPED_SQL_WRITE_BATCH_MEMBERS).contains(&len) {
12777        Ok(())
12778    } else {
12779        Err(NodeError::InvalidRequest(format!(
12780            "SQL write batch must contain 1..={MAX_TYPED_SQL_WRITE_BATCH_MEMBERS} commands"
12781        )))
12782    }
12783}
12784
12785fn validate_command_size(payload: &[u8]) -> Result<(), NodeError> {
12786    if payload.len() <= MAX_COMMAND_BYTES {
12787        Ok(())
12788    } else {
12789        Err(NodeError::InvalidRequest(format!(
12790            "command exceeds {MAX_COMMAND_BYTES} bytes"
12791        )))
12792    }
12793}
12794
12795#[cfg(feature = "sql")]
12796fn canonical_put(request_id: &str, key: &str, value: &str) -> Result<Vec<u8>, NodeError> {
12797    validate_field("request_id", request_id, MAX_REQUEST_ID_BYTES, false)?;
12798    validate_key(key)?;
12799    validate_field("value", value, MAX_VALUE_BYTES, true)?;
12800    let payload = encode_put_request(request_id, key, value)
12801        .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
12802    validate_command_size(&payload)?;
12803    Ok(payload)
12804}
12805
12806#[cfg(feature = "sql")]
12807fn encode_sql_command_with_index(command: &SqlCommand) -> Result<Vec<u8>, NodeError> {
12808    encode_sql_command(command).map_err(|error| {
12809        let message = error.to_string();
12810        match first_invalid_sql_statement(command, |prefix| encode_sql_command(prefix).is_err()) {
12811            Some(statement_index) => NodeError::InvalidSqlStatement {
12812                statement_index,
12813                message,
12814            },
12815            None => NodeError::InvalidRequest(message),
12816        }
12817    })
12818}
12819
12820#[cfg(feature = "sql")]
12821fn first_invalid_sql_statement(
12822    command: &SqlCommand,
12823    mut invalid: impl FnMut(&SqlCommand) -> bool,
12824) -> Option<usize> {
12825    if command.statements.is_empty() || command.statements.len() > MAX_SQL_STATEMENTS {
12826        return None;
12827    }
12828    (0..command.statements.len()).find(|statement_index| {
12829        let prefix = SqlCommand {
12830            request_id: command.request_id.clone(),
12831            statements: command.statements[..=*statement_index].to_vec(),
12832        };
12833        invalid(&prefix)
12834    })
12835}
12836
12837#[cfg(feature = "sql")]
12838fn validate_key(key: &str) -> Result<(), NodeError> {
12839    validate_field("key", key, MAX_KEY_BYTES, false)
12840}
12841
12842#[cfg(feature = "sql")]
12843fn validate_field(
12844    name: &str,
12845    value: &str,
12846    max_bytes: usize,
12847    allow_empty: bool,
12848) -> Result<(), NodeError> {
12849    if !allow_empty && value.is_empty() {
12850        return Err(NodeError::InvalidRequest(format!(
12851            "{name} must not be empty"
12852        )));
12853    }
12854    if value.len() > max_bytes {
12855        return Err(NodeError::InvalidRequest(format!(
12856            "{name} exceeds {max_bytes} bytes"
12857        )));
12858    }
12859    if value.contains('\t') {
12860        return Err(NodeError::InvalidRequest(format!(
12861            "{name} must not contain a tab"
12862        )));
12863    }
12864    Ok(())
12865}
12866
12867#[cfg(feature = "sql")]
12868fn write_response(outcome: RequestOutcome) -> WriteResponse {
12869    WriteResponse {
12870        applied_index: outcome.original_log_index(),
12871        hash: outcome.original_log_hash(),
12872    }
12873}
12874
12875fn reconcile_local_storage(
12876    config: &NodeConfig,
12877    log_store: &FileLogStore,
12878    materializer: &Materializer,
12879) -> Result<(), NodeError> {
12880    let mut log_state = log_store
12881        .logical_state()
12882        .map_err(|error| NodeError::Storage(error.to_string()))?;
12883    let mut log_last_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
12884    let applied_index = materializer
12885        .applied_index()
12886        .map_err(|error| NodeError::Storage(error.to_string()))?;
12887    let applied_hash = materializer
12888        .applied_hash()
12889        .map_err(|error| NodeError::Storage(error.to_string()))?;
12890    let mut materializer_configuration = materializer
12891        .configuration_state()
12892        .map_err(|error| NodeError::Storage(error.to_string()))?;
12893
12894    if let Some(anchor) = &log_state.anchor {
12895        if anchor.recovery_generation() != config.recovery_generation {
12896            return Err(NodeError::Reconciliation(format!(
12897                "qlog anchor recovery generation {} differs from runtime generation {}",
12898                anchor.recovery_generation(),
12899                config.recovery_generation
12900            )));
12901        }
12902        if applied_index < anchor.compacted().index() {
12903            return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())));
12904        }
12905    }
12906    if applied_index > log_last_index {
12907        let entries: Option<Vec<LogEntry>> = match materializer {
12908            #[cfg(feature = "sql")]
12909            Materializer::Sql(sql) => Some(
12910                sql.embedded_log_entries(log_last_index.saturating_add(1), applied_index)
12911                    .map_err(|error| NodeError::Reconciliation(error.to_string()))?,
12912            ),
12913            #[cfg(feature = "kv")]
12914            Materializer::Kv(kv) => Some(
12915                kv.embedded_log_entries(log_last_index.saturating_add(1), applied_index)
12916                    .map_err(|error| NodeError::Reconciliation(error.to_string()))?,
12917            ),
12918            #[cfg(feature = "graph")]
12919            Materializer::Graph(_) => None,
12920            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
12921            Materializer::Unavailable => None,
12922        };
12923        if let Some(entries) = entries {
12924            log_store
12925                .append_batch(&entries)
12926                .map_err(|error| NodeError::Storage(error.to_string()))?;
12927            log_state = log_store
12928                .logical_state()
12929                .map_err(|error| NodeError::Storage(error.to_string()))?;
12930            log_last_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
12931        }
12932    }
12933    if applied_index > log_last_index {
12934        return Err(NodeError::Reconciliation(format!(
12935            "{} materializer is ahead at {applied_index}, qlog ends at {log_last_index}",
12936            materializer.profile()
12937        )));
12938    }
12939    if applied_index == 0 {
12940        if applied_hash != LogHash::ZERO {
12941            return Err(NodeError::Reconciliation(format!(
12942                "{} materializer genesis hash is not zero",
12943                materializer.profile()
12944            )));
12945        }
12946    } else if !log_state.anchor.as_ref().is_some_and(|anchor| {
12947        applied_index == anchor.compacted().index() && applied_hash == anchor.compacted().hash()
12948    }) {
12949        let entry = log_store
12950            .read(applied_index)
12951            .map_err(|error| NodeError::Storage(error.to_string()))?
12952            .ok_or_else(|| {
12953                NodeError::Reconciliation(format!(
12954                    "qlog prefix is missing {} materializer index {applied_index}",
12955                    materializer.profile()
12956                ))
12957            })?;
12958        validate_entry_envelope(config, &entry, applied_index, entry.prev_hash)?;
12959        if entry.hash != applied_hash {
12960            return Err(NodeError::Reconciliation(format!(
12961                "{} materializer hash diverges from qlog at index {applied_index}",
12962                materializer.profile()
12963            )));
12964        }
12965    }
12966
12967    let mut expected_prev_hash = applied_hash;
12968    for index in (applied_index + 1)..=log_last_index {
12969        let entry = log_store
12970            .read(index)
12971            .map_err(|error| NodeError::Storage(error.to_string()))?
12972            .ok_or_else(|| {
12973                NodeError::Reconciliation(format!("qlog prefix is missing index {index}"))
12974            })?;
12975        match &materializer_configuration {
12976            Some(configuration) => {
12977                validate_runtime_entry(config, configuration, &entry, index, expected_prev_hash)?
12978            }
12979            None => validate_entry_envelope(config, &entry, index, expected_prev_hash)?,
12980        };
12981        materializer
12982            .apply_entry(&entry)
12983            .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12984        materializer_configuration = materializer
12985            .configuration_state()
12986            .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12987        expected_prev_hash = entry.hash;
12988    }
12989    let log_configuration = log_store
12990        .configuration_state()
12991        .map_err(|error| NodeError::Storage(error.to_string()))?;
12992    if materializer_configuration
12993        .as_ref()
12994        .is_some_and(|configuration| configuration != &log_configuration)
12995    {
12996        return Err(NodeError::Reconciliation(format!(
12997            "qlog and {} materializer configuration states disagree",
12998            materializer.profile()
12999        )));
13000    }
13001    Ok(())
13002}
13003
13004fn recover_peer_candidates(
13005    config: &NodeConfig,
13006    consensus: &ThreeNodeConsensus,
13007    log_store: &FileLogStore,
13008    materializer: &Materializer,
13009    peer_candidates: &[&dyn LogPeer],
13010    startup: &StartupIoContext,
13011) -> Result<(), NodeError> {
13012    for peer in peer_candidates {
13013        let (last_index, last_hash) = static_log_tip(log_store)?;
13014        startup.check("peer log fetch")?;
13015        let candidates = match peer.fetch_log(FetchLogRequest {
13016            from_index: last_index.saturating_add(1),
13017            max_entries: MAX_FETCH_ENTRIES,
13018        }) {
13019            Ok(response) => validate_fetched_entries_with_configuration(
13020                last_index.saturating_add(1),
13021                last_hash,
13022                &config.cluster_id,
13023                config.epoch,
13024                log_store
13025                    .configuration_state()
13026                    .map_err(|error| NodeError::Storage(error.to_string()))?,
13027                response.entries,
13028            )
13029            .map_err(|error| {
13030                NodeError::Reconciliation(format!("peer candidate validation failed: {error}"))
13031            })?,
13032            Err(FetchLogError::Transport { .. }) => continue,
13033            Err(FetchLogError::SnapshotRequired { anchor }) => {
13034                return Err(NodeError::SnapshotRequired(anchor));
13035            }
13036            Err(error) => {
13037                return Err(NodeError::Reconciliation(format!(
13038                    "peer candidate validation failed: {error}"
13039                )));
13040            }
13041        };
13042        startup.check("peer log fetch")?;
13043
13044        let mut expected_index = last_index.checked_add(1).ok_or_else(|| {
13045            NodeError::Reconciliation("qlog index is exhausted during peer catch-up".into())
13046        })?;
13047        let mut expected_prev_hash = last_hash;
13048        for candidate in candidates {
13049            startup.check("peer decision inspection")?;
13050            match consensus
13051                .inspect_decision_at(expected_index, expected_prev_hash)
13052                .map_err(startup_consensus_error)?
13053            {
13054                DecisionInspection::Committed(committed) if committed == candidate => {
13055                    startup.check("peer decision inspection")?;
13056                    persist_startup_entry(
13057                        config,
13058                        log_store,
13059                        materializer,
13060                        &candidate,
13061                        expected_index,
13062                        expected_prev_hash,
13063                        startup,
13064                    )?;
13065                    expected_prev_hash = candidate.hash;
13066                    expected_index = expected_index.checked_add(1).ok_or_else(|| {
13067                        NodeError::Reconciliation(
13068                            "qlog index is exhausted during peer catch-up".into(),
13069                        )
13070                    })?;
13071                }
13072                DecisionInspection::Committed(_) => {
13073                    return Err(NodeError::Reconciliation(format!(
13074                        "peer candidate at index {expected_index} differs from committed decision"
13075                    )));
13076                }
13077                DecisionInspection::Unavailable => {
13078                    return Err(NodeError::Unavailable(format!(
13079                        "decision inspection unavailable for peer candidate at index {expected_index}"
13080                    )));
13081                }
13082                DecisionInspection::Empty | DecisionInspection::Pending => {
13083                    return Err(NodeError::Reconciliation(format!(
13084                        "peer candidate at index {expected_index} is not committed"
13085                    )));
13086                }
13087            }
13088        }
13089    }
13090    Ok(())
13091}
13092
13093fn recover_startup_decisions(
13094    config: &NodeConfig,
13095    consensus: &ThreeNodeConsensus,
13096    log_store: &FileLogStore,
13097    materializer: &Materializer,
13098    startup: &StartupIoContext,
13099) -> Result<(), NodeError> {
13100    for _ in 0..MAX_STARTUP_RECOVERY_ENTRIES {
13101        let (last_index, last_hash) = static_log_tip(log_store)?;
13102        let slot = last_index.checked_add(1).ok_or_else(|| {
13103            NodeError::Reconciliation("qlog index is exhausted during startup".into())
13104        })?;
13105        startup.check("recorder decision inspection")?;
13106        match consensus
13107            .inspect_decision_at(slot, last_hash)
13108            .map_err(startup_consensus_error)?
13109        {
13110            DecisionInspection::Committed(entry) => {
13111                startup.check("recorder decision inspection")?;
13112                persist_startup_entry(
13113                    config,
13114                    log_store,
13115                    materializer,
13116                    &entry,
13117                    slot,
13118                    last_hash,
13119                    startup,
13120                )?;
13121            }
13122            DecisionInspection::Pending => {
13123                let entry = consensus
13124                    .propose_at_cancellable(
13125                        slot,
13126                        last_hash,
13127                        Command::new(CommandKind::ReadBarrier, Vec::new()),
13128                        startup.cancellation_flag(),
13129                    )
13130                    .map_err(startup_consensus_error)?;
13131                startup.check("recorder pending decision recovery")?;
13132                persist_startup_entry(
13133                    config,
13134                    log_store,
13135                    materializer,
13136                    &entry,
13137                    slot,
13138                    last_hash,
13139                    startup,
13140                )?;
13141            }
13142            DecisionInspection::Empty => return Ok(()),
13143            DecisionInspection::Unavailable => {
13144                return Err(NodeError::Unavailable(
13145                    "decision inspection unavailable during startup".into(),
13146                ));
13147            }
13148        }
13149    }
13150    Err(NodeError::Reconciliation(format!(
13151        "startup recovery exceeded {MAX_STARTUP_RECOVERY_ENTRIES} entries"
13152    )))
13153}
13154
13155fn persist_startup_entry(
13156    config: &NodeConfig,
13157    log_store: &FileLogStore,
13158    materializer: &Materializer,
13159    entry: &LogEntry,
13160    expected_index: LogIndex,
13161    expected_prev_hash: LogHash,
13162    startup: &StartupIoContext,
13163) -> Result<(), NodeError> {
13164    startup.persist("startup qlog and materializer persistence", || {
13165        let configuration_state = log_store
13166            .configuration_state()
13167            .map_err(|error| NodeError::Storage(error.to_string()))?;
13168        validate_runtime_entry(
13169            config,
13170            &configuration_state,
13171            entry,
13172            expected_index,
13173            expected_prev_hash,
13174        )?;
13175        log_store
13176            .append(entry)
13177            .map_err(|error| NodeError::Storage(error.to_string()))?;
13178        materializer
13179            .apply_entry(entry)
13180            .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
13181        Ok(())
13182    })
13183}
13184
13185fn static_log_tip(log_store: &FileLogStore) -> Result<(LogIndex, LogHash), NodeError> {
13186    Ok(log_store
13187        .logical_state()
13188        .map_err(|error| NodeError::Storage(error.to_string()))?
13189        .tip
13190        .map_or((0, LogHash::ZERO), |tip| (tip.index(), tip.hash())))
13191}
13192
13193fn validate_runtime_entry(
13194    config: &NodeConfig,
13195    configuration_state: &ConfigurationState,
13196    entry: &LogEntry,
13197    expected_index: LogIndex,
13198    expected_prev_hash: LogHash,
13199) -> Result<(), NodeError> {
13200    validate_entry_envelope(config, entry, expected_index, expected_prev_hash)?;
13201    validate_profile_entry_shape(config.execution_profile(), entry)
13202        .map_err(NodeError::Invariant)?;
13203    configuration_state
13204        .validate_entry(entry)
13205        .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
13206    Ok(())
13207}
13208
13209fn validate_entry_envelope(
13210    config: &NodeConfig,
13211    entry: &LogEntry,
13212    expected_index: LogIndex,
13213    expected_prev_hash: LogHash,
13214) -> Result<(), NodeError> {
13215    if entry.index != expected_index {
13216        return Err(NodeError::Reconciliation(format!(
13217            "expected decision index {expected_index}, got {}",
13218            entry.index
13219        )));
13220    }
13221    if entry.cluster_id != config.cluster_id || entry.epoch != config.epoch {
13222        return Err(NodeError::Reconciliation(format!(
13223            "decision {} has a foreign identity",
13224            entry.index
13225        )));
13226    }
13227    if entry.prev_hash != expected_prev_hash {
13228        return Err(NodeError::Reconciliation(format!(
13229            "decision {} has a conflicting predecessor",
13230            entry.index
13231        )));
13232    }
13233    if entry.recompute_hash() != entry.hash {
13234        return Err(NodeError::Reconciliation(format!(
13235            "decision {} has an invalid hash",
13236            entry.index
13237        )));
13238    }
13239    validate_entry_shape(entry).map_err(NodeError::Invariant)
13240}
13241
13242fn validate_entry_shape(entry: &LogEntry) -> Result<(), String> {
13243    match entry.entry_type {
13244        EntryType::Command if entry.payload.len() <= MAX_COMMAND_BYTES => Ok(()),
13245        EntryType::Command => Err(format!("command exceeds {MAX_COMMAND_BYTES} bytes")),
13246        EntryType::Noop if entry.payload.is_empty() => Ok(()),
13247        EntryType::Noop => Err("Noop payload must be empty".into()),
13248        EntryType::ConfigChange => ConfigChange::recognize(&StoredCommand::new(
13249            EntryType::ConfigChange,
13250            entry.payload.clone(),
13251        ))
13252        .map(|_| ())
13253        .map_err(|error| error.to_string()),
13254        other => Err(format!("unsupported runtime entry type {other:?}")),
13255    }
13256}
13257
13258pub(crate) fn validate_profile_entry_shape(
13259    _profile: ExecutionProfile,
13260    entry: &LogEntry,
13261) -> Result<(), String> {
13262    validate_entry_shape(entry)?;
13263    #[cfg(feature = "sql")]
13264    if _profile == ExecutionProfile::Sqlite && entry.entry_type == EntryType::Command {
13265        decode_qwal_v3(&entry.payload)
13266            .map_err(|error| format!("SQLite command is not canonical QWAL v3: {error}"))?;
13267    }
13268    Ok(())
13269}
13270
13271fn startup_consensus_error(error: rhiza_quepaxa::Error) -> NodeError {
13272    match error {
13273        rhiza_quepaxa::Error::NoQuorum
13274        | rhiza_quepaxa::Error::CommandUnavailable
13275        | rhiza_quepaxa::Error::Cancelled
13276        | rhiza_quepaxa::Error::Io(_) => NodeError::Unavailable(error.to_string()),
13277        other => NodeError::Reconciliation(other.to_string()),
13278    }
13279}
13280
13281#[cfg(feature = "sql")]
13282#[derive(Clone, Debug, Eq, PartialEq)]
13283pub struct E2eConfig {
13284    pub data_dir: PathBuf,
13285    pub object_store: ObjStoreConfig,
13286    pub cluster_id: String,
13287    pub node_id: String,
13288}
13289
13290#[cfg(feature = "sql")]
13291#[derive(Clone, Debug, Eq, PartialEq)]
13292pub struct E2eReport {
13293    pub applied_index: LogIndex,
13294    pub restored_value: String,
13295    pub object_keys: Vec<String>,
13296}
13297
13298#[cfg(feature = "sql")]
13299pub async fn run_e2e(config: E2eConfig) -> Result<E2eReport, Box<dyn std::error::Error>> {
13300    let sqlite_dir = config.data_dir.join("sqlite");
13301    let log_dir = config.data_dir.join("consensus").join("log");
13302    ensure_fresh_e2e_data_dir(&config.data_dir, &sqlite_dir, &log_dir)?;
13303
13304    fs::create_dir_all(&config.data_dir)?;
13305    let db_path = sqlite_dir.join("db.sqlite");
13306    let restore_path = config.data_dir.join("restore").join("db.sqlite");
13307    let db = SqliteStateMachine::open(&db_path, &config.cluster_id, &config.node_id, 1, 1)?;
13308    let recorder_dir = config.data_dir.join("consensus").join("recorder");
13309    let consensus = ThreeNodeConsensus::new(
13310        &config.cluster_id,
13311        &config.node_id,
13312        1,
13313        1,
13314        [
13315            recorder_dir.join("node-1"),
13316            recorder_dir.join("node-2"),
13317            recorder_dir.join("node-3"),
13318        ],
13319    )?;
13320    let base_request = canonical_put("e2e-base", "alpha", "bravo")?;
13321    let base_effect = db.prepare_put_effect(
13322        "e2e-base",
13323        "alpha",
13324        "bravo",
13325        &base_request,
13326        0,
13327        LogHash::ZERO,
13328    )?;
13329    let base_entry = consensus.propose(Command::new(CommandKind::Deterministic, base_effect))?;
13330    db.apply_entry(&base_entry)?;
13331    let snapshot = db.create_snapshot(base_entry.index)?;
13332
13333    let tail_request = canonical_put("e2e-tail", "alpha", "charlie")?;
13334    let tail_effect = db.prepare_put_effect(
13335        "e2e-tail",
13336        "alpha",
13337        "charlie",
13338        &tail_request,
13339        base_entry.index,
13340        base_entry.hash,
13341    )?;
13342    let tail_entry = consensus.propose(Command::new(CommandKind::Deterministic, tail_effect))?;
13343    let segment_path = write_segment_file(&log_dir, std::slice::from_ref(&tail_entry))?;
13344    let segment = rhiza_log::SegmentFile::new(
13345        IndexRange::new(tail_entry.index, tail_entry.index)?,
13346        fs::read(&segment_path)?,
13347    );
13348    db.apply_entry(&tail_entry)?;
13349
13350    let local_archive = matches!(&config.object_store, ObjStoreConfig::Local { .. });
13351    let store = ObjStore::new(config.object_store)?;
13352    let archive = if local_archive {
13353        rhiza_archive::ObjectArchiveStore::new_for_single_process(
13354            store.clone(),
13355            config.cluster_id.clone(),
13356        )
13357    } else {
13358        rhiza_archive::ObjectArchiveStore::new(store.clone(), config.cluster_id.clone())?
13359    };
13360
13361    let segment_record = archive.publish_segment(tail_entry.epoch, &segment).await?;
13362    let snapshot_record = archive.publish_snapshot(&snapshot).await?;
13363    let (mut archive_manifest, expected_manifest_version) = match archive.load_manifest().await? {
13364        Some(loaded) => (loaded.manifest().clone(), Some(loaded.version().clone())),
13365        None => (
13366            rhiza_archive::ArchiveManifest::new(config.cluster_id.clone()),
13367            None,
13368        ),
13369    };
13370    archive_manifest.set_latest_snapshot(snapshot_record);
13371    archive_manifest.add_segment(segment_record);
13372    archive
13373        .publish_manifest(&archive_manifest, expected_manifest_version)
13374        .await?;
13375
13376    let loaded_manifest = archive
13377        .load_manifest()
13378        .await?
13379        .ok_or("published archive manifest is missing")?;
13380    if loaded_manifest.manifest() != &archive_manifest {
13381        return Err("reloaded archive manifest did not match the published manifest".into());
13382    }
13383    let archived_snapshot = loaded_manifest
13384        .manifest()
13385        .latest_snapshot()
13386        .ok_or("archive manifest is missing its snapshot")?;
13387    let archived_segment = loaded_manifest
13388        .manifest()
13389        .segments()
13390        .iter()
13391        .find(|record| {
13392            record.start_index() == tail_entry.index && record.end_index() == tail_entry.index
13393        })
13394        .ok_or("archive manifest is missing its post-snapshot segment")?;
13395
13396    let downloaded_segment = archive.download_segment(archived_segment).await?;
13397    let downloaded_entries = decode_segment_for_cluster(&downloaded_segment, &config.cluster_id)?;
13398    if downloaded_entries.as_slice() != std::slice::from_ref(&tail_entry) {
13399        return Err("downloaded qlog segment did not match written entry".into());
13400    }
13401    let downloaded_snapshot = rhiza_core::Snapshot::new(
13402        archived_snapshot.manifest().clone(),
13403        archive.download_snapshot(archived_snapshot).await?,
13404    );
13405    restore_snapshot_file(&restore_path, &downloaded_snapshot, &config.node_id)?;
13406    let restored_db = SqliteStateMachine::open_existing(&restore_path)?;
13407    if restored_db.get_value("alpha")?.as_deref() != Some("bravo") {
13408        return Err("restored base snapshot is missing alpha=bravo".into());
13409    }
13410    for entry in &downloaded_entries {
13411        restored_db.apply_entry(entry)?;
13412    }
13413    let restored_value = restored_db
13414        .get_value("alpha")?
13415        .ok_or("restored SQLite state is missing alpha")?;
13416    let applied_index = restored_db.applied_index_value()?;
13417    if applied_index != tail_entry.index || restored_value != "charlie" {
13418        return Err("restored SQLite state did not include the archived log tail".into());
13419    }
13420    let object_keys = store.list(&format!("rhiza/{}", config.cluster_id)).await?;
13421
13422    Ok(E2eReport {
13423        applied_index,
13424        restored_value,
13425        object_keys,
13426    })
13427}
13428
13429#[cfg(feature = "sql")]
13430fn ensure_fresh_e2e_data_dir(
13431    data_dir: &std::path::Path,
13432    sqlite_dir: &std::path::Path,
13433    log_dir: &std::path::Path,
13434) -> Result<(), Box<dyn std::error::Error>> {
13435    if directory_has_entries(sqlite_dir)? || directory_has_entries(log_dir)? {
13436        return Err(format!(
13437            "e2e data directory is not fresh: prior SQLite/qlog data exists in {}",
13438            data_dir.display()
13439        )
13440        .into());
13441    }
13442    Ok(())
13443}
13444
13445#[cfg(feature = "sql")]
13446fn directory_has_entries(path: &std::path::Path) -> Result<bool, std::io::Error> {
13447    match fs::read_dir(path) {
13448        Ok(mut entries) => entries.next().transpose().map(|entry| entry.is_some()),
13449        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
13450        Err(error) => Err(error),
13451    }
13452}