Skip to main content

rhiza_node/
lib.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fmt, fs,
4    path::{Path, PathBuf},
5    sync::{
6        atomic::{AtomicBool, Ordering},
7        Arc, Condvar, Mutex, MutexGuard, OnceLock,
8    },
9    time::{Duration, Instant},
10};
11
12#[cfg(feature = "sql")]
13use std::collections::VecDeque;
14
15#[cfg(not(feature = "sql"))]
16compile_error!("rhiza-node requires the sql execution profile feature");
17
18#[cfg(feature = "graph")]
19use std::collections::BTreeMap;
20#[cfg(any(feature = "sql", test))]
21use std::sync::atomic::AtomicUsize;
22
23use axum::{
24    extract::{rejection::JsonRejection, DefaultBodyLimit, Extension, Request, State},
25    http::{HeaderMap, StatusCode},
26    middleware::{self, Next},
27    response::{IntoResponse, Response},
28    routing::{get, post},
29    Json, Router,
30};
31#[cfg(feature = "sql")]
32use rhiza_archive::SnapshotRecord;
33use rhiza_core::{
34    Command, CommandKind, ConfigChange, ConfigurationState, EntryType, ErrorClassification,
35    ExecutionProfile, LogAnchor, LogEntry, LogHash, LogIndex, RecoveryAnchor, StoredCommand,
36};
37#[cfg(feature = "graph")]
38use rhiza_graph::{
39    encode_replicated_graph_batch, encode_replicated_graph_command, CanonicalF64, GraphColumn,
40    GraphInternalId, GraphLogicalType, GraphNode, GraphParameterValue, GraphQueryResult, GraphRel,
41    GraphResultValue, LadybugStateMachine, RequestRecord as GraphRequestRecord,
42};
43#[cfg(feature = "kv")]
44use rhiza_kv::{
45    encode_replicated_kv_batch, encode_replicated_kv_command, KvRequestRecord, RedbStateMachine,
46    MAX_KV_BATCH_MEMBERS,
47};
48#[cfg(feature = "kv")]
49pub use rhiza_kv::{KvScanResult, KvScanRow, MAX_KV_SCAN_RESULT_BYTES, MAX_KV_SCAN_ROWS};
50#[cfg(feature = "sql")]
51use rhiza_log::{decode_segment_for_cluster, write_segment_file};
52use rhiza_log::{FileLogStore, IndexRange, LogStore};
53#[cfg(feature = "sql")]
54use rhiza_obj_store::{ObjStore, ObjStoreConfig};
55#[cfg(feature = "sql")]
56use rhiza_quepaxa::Consensus;
57use rhiza_quepaxa::{
58    CertifiedDecisionInspection, DecisionInspection, DecisionProof, Membership,
59    ReadFenceObservation, ReadFenceRequest, RecordRequest, RecordSummary, RecorderFileStore,
60    RecorderRpc, RejectReason, ThreeNodeConsensus,
61};
62#[cfg(feature = "sql")]
63use rhiza_sql::{
64    decode_qwal_v3, encode_put_request, encode_sql_command, restore_snapshot_file,
65    RecoverySnapshot, RequestConflict, RequestOutcome, SqlBatchMember, SqlCommand,
66    SqlCommandResult, SqlQueryResult, SqlStatement, SqlValue, SqliteStateMachine,
67    MAX_QWAL_V3_RECEIPTS, MAX_SQL_STATEMENTS, QWAL_V3_MAGIC,
68};
69#[cfg(not(feature = "sql"))]
70type SqlCommandResult = ();
71
72mod admin;
73pub mod durability;
74#[cfg(feature = "graph")]
75mod graph;
76#[cfg(feature = "kv")]
77mod kv;
78mod learner;
79mod recorder_tcp;
80pub use admin::*;
81pub use durability::{
82    restore_checkpoint_to_fresh_data_dir, restore_checkpoint_to_fresh_data_dir_for_node,
83    CheckpointCoordinator, DurabilityError, DurabilityHealth, DurabilityMode,
84    SuccessorRestorePreparation,
85};
86#[cfg(feature = "graph")]
87pub use graph::*;
88#[cfg(feature = "kv")]
89pub use kv::*;
90pub use learner::*;
91#[cfg(feature = "recorder-postcard-rpc")]
92pub use recorder_tcp::{
93    serve_recorder_postcard_rpc, serve_recorder_postcard_rpc_tls,
94    RecorderPostcardRpcTlsClientConfig, RecorderPostcardRpcTlsServerConfig,
95    TcpPostcardRpcRecorderClient,
96};
97pub use recorder_tcp::{
98    serve_recorder_tcp, serve_recorder_tcp_tls, validate_recorder_tcp_endpoint,
99    RecorderTlsClientConfig, RecorderTlsServerConfig, TcpPostcardRecorderClient,
100};
101
102pub const MAX_FETCH_ENTRIES: u32 = 1_024;
103pub const MAX_COMMAND_BYTES: usize = 512 * 1024;
104pub const MAX_REQUEST_ID_BYTES: usize = 256;
105pub const MAX_KEY_BYTES: usize = 4 * 1024;
106pub const MAX_VALUE_BYTES: usize = 240 * 1024;
107pub const MAX_HTTP_BODY_BYTES: usize = MAX_COMMAND_BYTES * 6 + 16 * 1024;
108pub const DEFAULT_CLIENT_CONCURRENCY: usize = 16;
109pub const DEFAULT_PEER_CONCURRENCY: usize = 32;
110pub const DEFAULT_WRITER_BATCH_MAX: usize = 8;
111const MAX_WRITE_BATCH_MEMBERS: usize = 64;
112#[cfg(feature = "sql")]
113const MAX_SQL_WRITE_BATCH_MEMBERS: usize = MAX_QWAL_V3_RECEIPTS;
114#[cfg(feature = "sql")]
115pub const MAX_TYPED_SQL_WRITE_BATCH_MEMBERS: usize = 256;
116#[cfg(feature = "sql")]
117pub const DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY: usize = 64;
118#[cfg(feature = "sql")]
119pub const MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY: usize = 4_096;
120// One full 1,024-receipt group is four aggregate-capped public calls.
121#[cfg(feature = "sql")]
122const MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES: usize = 4 * MAX_COMMAND_BYTES;
123// Keep the configured call limit for compatibility, but never retain more than 64 full calls.
124#[cfg(feature = "sql")]
125const MAX_SQL_GROUP_COMMIT_PENDING_BYTES: usize =
126    DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY * MAX_COMMAND_BYTES;
127#[cfg(feature = "kv")]
128const MAX_KV_GROUP_COMMIT_MEMBERS: usize = 1_024;
129#[cfg(feature = "kv")]
130const KV_GROUP_COMMIT_QUEUE_CAPACITY: usize = 64;
131#[cfg(feature = "kv")]
132const MAX_KV_GROUP_COMMIT_PENDING_BYTES: usize = KV_GROUP_COMMIT_QUEUE_CAPACITY * MAX_COMMAND_BYTES;
133pub const DEFAULT_WRITER_BATCH_WINDOW: Duration = Duration::from_micros(500);
134pub const PROTOCOL_VERSION: &str = "1";
135pub const RECORDER_PROTOCOL_VERSION: &str = "3";
136const RECORDER_WIRE_VERSION: u16 = 3;
137pub const VERSION_HEADER: &str = "x-rhiza-version";
138pub const NODE_ID_HEADER: &str = "x-rhiza-node-id";
139pub const RECOVERY_GENERATION_HEADER: &str = "x-rhiza-recovery-generation";
140pub const RECORDER_IDENTITY_PATH: &str = "/v2/quepaxa/recorder/identity";
141
142/// A bounded, clone-shared observer for successful physical SQL write batches.
143///
144/// Installing this observer enables write-phase timing. A runtime without an observer does not
145/// read the clock or synchronize on the profiling path.
146#[cfg(feature = "sql")]
147#[derive(Clone)]
148pub struct SqlWriteProfiler {
149    inner: Arc<SqlWriteProfilerInner>,
150}
151
152#[cfg(feature = "sql")]
153struct SqlWriteProfilerInner {
154    capacity: usize,
155    state: Mutex<SqlWriteProfilerState>,
156}
157
158#[cfg(feature = "sql")]
159#[derive(Default)]
160struct SqlWriteProfilerState {
161    samples: VecDeque<SqlWriteProfileSample>,
162    dropped_samples: u64,
163}
164
165/// Timing for one successfully committed physical SQL QWAL batch.
166#[cfg(feature = "sql")]
167#[derive(Clone, Debug, Eq, PartialEq)]
168pub struct SqlWriteProfileSample {
169    pub batch_member_count: usize,
170    pub commit_lock_wait_us: u64,
171    pub precheck_classification_us: u64,
172    pub qwal_prepare_us: u64,
173    pub consensus_propose_us: u64,
174    pub local_qlog_mirror_append_us: u64,
175    pub sql_materializer_apply_us: u64,
176    pub response_other_total_us: u64,
177    pub total_service_us: u64,
178}
179
180/// A point-in-time copy of a SQL write profiler's bounded samples.
181#[cfg(feature = "sql")]
182#[derive(Clone, Debug, Default, Eq, PartialEq)]
183pub struct SqlWriteProfileSnapshot {
184    pub samples: Vec<SqlWriteProfileSample>,
185    pub dropped_samples: u64,
186}
187
188#[cfg(feature = "sql")]
189impl SqlWriteProfiler {
190    /// Creates an observer that retains at most `capacity` of the newest samples.
191    ///
192    /// # Panics
193    ///
194    /// Panics when `capacity` is zero.
195    pub fn new(capacity: usize) -> Self {
196        assert!(capacity > 0, "SQL write profiler capacity must be non-zero");
197        Self {
198            inner: Arc::new(SqlWriteProfilerInner {
199                capacity,
200                state: Mutex::new(SqlWriteProfilerState::default()),
201            }),
202        }
203    }
204
205    pub fn snapshot(&self) -> SqlWriteProfileSnapshot {
206        let state = self
207            .inner
208            .state
209            .lock()
210            .unwrap_or_else(std::sync::PoisonError::into_inner);
211        SqlWriteProfileSnapshot {
212            samples: state.samples.iter().cloned().collect(),
213            dropped_samples: state.dropped_samples,
214        }
215    }
216
217    /// Returns and clears retained samples while preserving the cumulative dropped count.
218    pub fn drain(&self) -> SqlWriteProfileSnapshot {
219        let mut state = self
220            .inner
221            .state
222            .lock()
223            .unwrap_or_else(std::sync::PoisonError::into_inner);
224        SqlWriteProfileSnapshot {
225            samples: state.samples.drain(..).collect(),
226            dropped_samples: state.dropped_samples,
227        }
228    }
229
230    fn record(&self, sample: SqlWriteProfileSample) {
231        let mut state = self
232            .inner
233            .state
234            .lock()
235            .unwrap_or_else(std::sync::PoisonError::into_inner);
236        if state.samples.len() == self.inner.capacity {
237            state.samples.pop_front();
238            state.dropped_samples = state.dropped_samples.saturating_add(1);
239        }
240        state.samples.push_back(sample);
241    }
242}
243
244#[cfg(feature = "sql")]
245impl fmt::Debug for SqlWriteProfiler {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        f.debug_struct("SqlWriteProfiler")
248            .field("capacity", &self.inner.capacity)
249            .finish_non_exhaustive()
250    }
251}
252
253#[cfg(feature = "sql")]
254impl PartialEq for SqlWriteProfiler {
255    fn eq(&self, other: &Self) -> bool {
256        Arc::ptr_eq(&self.inner, &other.inner)
257    }
258}
259
260#[cfg(feature = "sql")]
261impl Eq for SqlWriteProfiler {}
262pub const RECORDER_STORE_COMMAND_PATH: &str = "/v2/quepaxa/recorder/store-command";
263pub const RECORDER_FETCH_COMMAND_PATH: &str = "/v2/quepaxa/recorder/fetch-command";
264pub const RECORDER_INSPECT_PROOF_PATH: &str = "/v2/quepaxa/recorder/inspect-proof";
265pub const RECORDER_INSPECT_RECORD_PATH: &str = "/v2/quepaxa/recorder/inspect-record";
266pub const RECORDER_READ_FENCE_PATH: &str = "/v3/quepaxa/recorder/read-fence";
267pub const RECORDER_RECORD_PATH: &str = "/v2/quepaxa/recorder/record";
268pub const RECORDER_INSTALL_PROOF_PATH: &str = "/v2/quepaxa/recorder/install-decision-proof";
269pub const LOG_FETCH_PATH: &str = "/v1/log/fetch";
270#[cfg(feature = "sql")]
271pub const WRITE_PATH: &str = "/v1/write";
272#[cfg(feature = "sql")]
273pub const READ_PATH: &str = "/v1/read";
274#[cfg(feature = "sql")]
275pub const SQL_EXECUTE_PATH: &str = "/v1/sql/execute";
276#[cfg(feature = "sql")]
277pub const SQL_QUERY_PATH: &str = "/v1/sql/query";
278#[cfg(feature = "graph")]
279pub const GRAPH_PUT_DOCUMENT_PATH: &str = "/v1/graph/documents/put";
280#[cfg(feature = "graph")]
281pub const GRAPH_DELETE_DOCUMENT_PATH: &str = "/v1/graph/documents/delete";
282#[cfg(feature = "graph")]
283pub const GRAPH_GET_DOCUMENT_PATH: &str = "/v1/graph/documents/get";
284#[cfg(feature = "graph")]
285pub const GRAPH_QUERY_PATH: &str = "/v1/graph/query";
286#[cfg(feature = "kv")]
287pub const KV_PUT_PATH: &str = "/v1/kv/put";
288#[cfg(feature = "kv")]
289pub const KV_DELETE_PATH: &str = "/v1/kv/delete";
290#[cfg(feature = "kv")]
291pub const KV_GET_PATH: &str = "/v1/kv/get";
292#[cfg(feature = "kv")]
293pub const KV_SCAN_PATH: &str = "/v1/kv/scan";
294pub const LIVEZ_PATH: &str = "/livez";
295pub const READYZ_PATH: &str = "/readyz";
296const MAX_STARTUP_RECOVERY_ENTRIES: usize = 100_000;
297const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
298const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
299const READ_FENCE_REQUEST_TIMEOUT: Duration = Duration::from_secs(1);
300// Leave enough of the public one-second write budget to classify and return a
301// lost-quorum attempt instead of racing the caller's ambiguous timeout.
302const QUORUM_RECORD_REQUEST_TIMEOUT: Duration = Duration::from_millis(250);
303const CLIENT_WRITE_WAIT_TIMEOUT: Duration = Duration::from_secs(1);
304const SYNC_FLUSH_RETRY_INITIAL: Duration = Duration::from_millis(50);
305const SYNC_FLUSH_RETRY_MAX: Duration = Duration::from_secs(1);
306
307fn map_quorum_record_transport_error(error: rhiza_quepaxa::Error) -> rhiza_quepaxa::Error {
308    match error {
309        rhiza_quepaxa::Error::Io(_) | rhiza_quepaxa::Error::Decode(_) => {
310            rhiza_quepaxa::Error::ProposeFailed
311        }
312        error => error,
313    }
314}
315#[cfg(feature = "sql")]
316pub const DEFAULT_SQL_MAX_ROWS: u32 = 1_000;
317#[cfg(feature = "sql")]
318pub const MAX_SQL_MAX_ROWS: u32 = 10_000;
319#[cfg(feature = "sql")]
320pub const MAX_SQL_RESULT_BYTES: usize = 1024 * 1024;
321#[cfg(feature = "sql")]
322pub const MAX_SQL_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
323#[cfg(feature = "kv")]
324type KvMemberCheck = (usize, Result<Option<KvRequestRecord>, NodeError>);
325#[cfg(feature = "kv")]
326pub const DEFAULT_KV_SCAN_LIMIT: u32 = 100;
327#[cfg(feature = "kv")]
328pub const MAX_KV_SCAN_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
329#[cfg(feature = "graph")]
330pub const DEFAULT_GRAPH_MAX_ROWS: u32 = 1_000;
331#[cfg(feature = "graph")]
332pub const MAX_GRAPH_MAX_ROWS: u32 = 10_000;
333#[cfg(feature = "graph")]
334pub const MAX_GRAPH_RESULT_BYTES: usize = 1024 * 1024;
335#[cfg(feature = "graph")]
336pub const MAX_GRAPH_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
337#[cfg(feature = "graph")]
338const GRAPH_QUERY_TIMEOUT_MS: u64 = 5_000;
339
340pub fn effective_cluster_id(
341    profile: ExecutionProfile,
342    logical_cluster_id: &str,
343) -> Result<String, ConfigError> {
344    if let Some(actual) = canonical_cluster_profile(logical_cluster_id) {
345        if actual != profile {
346            return Err(ConfigError::ClusterIdProfileMismatch {
347                expected: profile,
348                actual,
349            });
350        }
351        return Ok(logical_cluster_id.to_owned());
352    }
353    Ok(format!("rhiza:{}:{logical_cluster_id}", profile.as_str()))
354}
355
356pub const fn execution_profile_compiled(profile: ExecutionProfile) -> bool {
357    match profile {
358        ExecutionProfile::Sqlite => cfg!(feature = "sql"),
359        ExecutionProfile::Graph => cfg!(feature = "graph"),
360        ExecutionProfile::Kv => cfg!(feature = "kv"),
361    }
362}
363
364fn canonical_cluster_profile(cluster_id: &str) -> Option<ExecutionProfile> {
365    [
366        ("rhiza:sql:", ExecutionProfile::Sqlite),
367        ("rhiza:graph:", ExecutionProfile::Graph),
368        ("rhiza:kv:", ExecutionProfile::Kv),
369    ]
370    .into_iter()
371    .find_map(|(prefix, profile)| cluster_id.starts_with(prefix).then_some(profile))
372}
373
374#[derive(Clone, Debug, Eq, PartialEq)]
375pub enum ConfigError {
376    EmptyClusterId,
377    ClusterIdProfileMismatch {
378        expected: ExecutionProfile,
379        actual: ExecutionProfile,
380    },
381    EmptyNodeId,
382    EmptyDataDir,
383    InvalidEpoch,
384    InvalidConfigId,
385    InvalidRecoveryGeneration,
386    InvalidWriterBatchMax(usize),
387    InvalidWriterBatchWindow,
388    #[cfg(feature = "sql")]
389    InvalidSqlGroupCommitQueueCapacity(usize),
390    EmptyPeerNodeId,
391    EmptyPeerBaseUrl,
392    InvalidPeerBaseUrl(String),
393    EmptyPeerToken,
394    DuplicatePeerToken,
395    InvalidPeerCount(usize),
396    DuplicatePeerNodeId(String),
397    LocalNodeMissing,
398    PeerMembershipMismatch,
399    InvalidPredecessorTransition(String),
400    EmptyClientToken,
401    ClientTokenConflictsWithPeer,
402    EmptyAdminToken,
403    AdminTokenConflictsWithRuntime,
404    HttpClient(String),
405}
406
407impl fmt::Display for ConfigError {
408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409        match self {
410            Self::EmptyClusterId => write!(f, "cluster_id must not be empty"),
411            Self::ClusterIdProfileMismatch { expected, actual } => write!(
412                f,
413                "cluster_id is canonical for the {} profile, not {}",
414                actual.as_str(),
415                expected.as_str()
416            ),
417            Self::EmptyNodeId => write!(f, "node_id must not be empty"),
418            Self::EmptyDataDir => write!(f, "data_dir must not be empty"),
419            Self::InvalidEpoch => write!(f, "epoch must be positive"),
420            Self::InvalidConfigId => write!(f, "config_id must be positive"),
421            Self::InvalidRecoveryGeneration => {
422                write!(f, "recovery_generation must be positive")
423            }
424            Self::InvalidWriterBatchMax(max) => write!(
425                f,
426                "writer batch max must be within 1..={MAX_WRITE_BATCH_MEMBERS}, got {max}"
427            ),
428            Self::InvalidWriterBatchWindow => write!(
429                f,
430                "writer batch window must be positive and shorter than the client deadline"
431            ),
432            #[cfg(feature = "sql")]
433            Self::InvalidSqlGroupCommitQueueCapacity(capacity) => write!(
434                f,
435                "SQL group commit queue capacity must be within 1..={MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY}, got {capacity}"
436            ),
437            Self::EmptyPeerNodeId => write!(f, "peer node_id must not be empty"),
438            Self::EmptyPeerBaseUrl => write!(f, "peer base_url must not be empty"),
439            Self::InvalidPeerBaseUrl(url) => write!(f, "invalid peer base_url: {url}"),
440            Self::EmptyPeerToken => write!(f, "peer token must not be empty"),
441            Self::DuplicatePeerToken => write!(f, "peer tokens must be unique"),
442            Self::InvalidPeerCount(count) => {
443                write!(
444                    f,
445                    "peer membership requires between three and seven nodes, got {count}"
446                )
447            }
448            Self::DuplicatePeerNodeId(node_id) => {
449                write!(f, "peer node_id must be unique: {node_id}")
450            }
451            Self::LocalNodeMissing => write!(f, "peer set must include the local node_id"),
452            Self::PeerMembershipMismatch => {
453                write!(
454                    f,
455                    "peer identities must exactly match the canonical membership"
456                )
457            }
458            Self::InvalidPredecessorTransition(message) => {
459                write!(f, "invalid predecessor transition: {message}")
460            }
461            Self::EmptyClientToken => write!(f, "client token must not be empty"),
462            Self::ClientTokenConflictsWithPeer => {
463                write!(f, "client token must differ from every peer token")
464            }
465            Self::EmptyAdminToken => write!(f, "admin token must not be empty"),
466            Self::AdminTokenConflictsWithRuntime => {
467                write!(f, "admin token must differ from client and peer tokens")
468            }
469            Self::HttpClient(message) => write!(f, "HTTP client configuration failed: {message}"),
470        }
471    }
472}
473
474impl std::error::Error for ConfigError {}
475
476fn validate_recovery_generation(recovery_generation: u64) -> Result<(), ConfigError> {
477    if recovery_generation == 0 {
478        Err(ConfigError::InvalidRecoveryGeneration)
479    } else {
480        Ok(())
481    }
482}
483
484#[derive(Clone, Copy, Debug, Eq, PartialEq)]
485pub enum AckMode {
486    HaFirst,
487    DrStrong,
488}
489
490#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
491#[serde(rename_all = "snake_case")]
492pub enum ReadConsistency {
493    Local,
494    ReadBarrier,
495    AppliedIndex(LogIndex),
496}
497
498#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
499pub struct FetchLogRequest {
500    pub from_index: LogIndex,
501    pub max_entries: u32,
502}
503
504#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
505pub struct FetchLogResponse {
506    pub entries: Vec<LogEntry>,
507    pub last_index: LogIndex,
508}
509
510#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
511pub enum FetchLogError {
512    SnapshotRequired {
513        anchor: Box<RecoveryAnchor>,
514    },
515    Gap {
516        expected: LogIndex,
517        actual: Option<LogIndex>,
518    },
519    Decode {
520        message: String,
521    },
522    Transport {
523        message: String,
524    },
525    InvalidAnchor {
526        expected: LogHash,
527        actual: LogHash,
528    },
529    InvalidEntry {
530        index: LogIndex,
531        message: String,
532    },
533    ForeignIdentity {
534        index: LogIndex,
535    },
536    InvalidRequest {
537        message: String,
538    },
539}
540
541impl fmt::Display for FetchLogError {
542    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
543        match self {
544            Self::SnapshotRequired { anchor } => {
545                write!(
546                    f,
547                    "snapshot restore required at qlog anchor {}",
548                    anchor.compacted().index()
549                )
550            }
551            Self::Gap { expected, actual } => {
552                write!(f, "qlog gap: expected {expected}, got {actual:?}")
553            }
554            Self::Decode { message } => write!(f, "qlog response decode failed: {message}"),
555            Self::Transport { message } => write!(f, "qlog transport failed: {message}"),
556            Self::InvalidAnchor { .. } => write!(f, "qlog response has an invalid anchor"),
557            Self::InvalidEntry { index, message } => {
558                write!(f, "qlog entry {index} is invalid: {message}")
559            }
560            Self::ForeignIdentity { index } => {
561                write!(f, "qlog entry {index} has a foreign identity")
562            }
563            Self::InvalidRequest { message } => write!(f, "invalid qlog request: {message}"),
564        }
565    }
566}
567
568impl std::error::Error for FetchLogError {}
569
570pub trait LogPeer: Send + Sync {
571    /// Fetches one bounded page.
572    ///
573    /// Process-bound implementations must use a finite deadline. HA shutdown
574    /// retains and joins an admitted call even after its deadline is reported,
575    /// so a non-returning custom implementation prevents quiescent shutdown.
576    fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError>;
577}
578
579#[derive(Clone, Debug)]
580pub struct StartupIoContext {
581    cancelled: Arc<AtomicBool>,
582    deadline: Arc<OnceLock<Instant>>,
583    stage: Arc<Mutex<&'static str>>,
584    mutation_admission: Arc<Mutex<()>>,
585}
586
587impl Default for StartupIoContext {
588    fn default() -> Self {
589        Self::new()
590    }
591}
592
593impl StartupIoContext {
594    pub fn new() -> Self {
595        Self {
596            cancelled: Arc::new(AtomicBool::new(false)),
597            deadline: Arc::new(OnceLock::new()),
598            stage: Arc::new(Mutex::new("runtime initialization")),
599            mutation_admission: Arc::new(Mutex::new(())),
600        }
601    }
602
603    pub fn cancel(&self, deadline: Instant) {
604        self.cancelled.store(true, Ordering::Release);
605        let _ = self.deadline.set(deadline);
606    }
607
608    pub fn unfinished_stage(&self) -> &'static str {
609        *self.lock_stage()
610    }
611
612    pub fn check(&self, stage: &'static str) -> Result<(), NodeError> {
613        *self.lock_stage() = stage;
614        if self.cancelled.load(Ordering::Acquire)
615            || self
616                .deadline
617                .get()
618                .is_some_and(|deadline| Instant::now() >= *deadline)
619        {
620            return Err(NodeError::Unavailable(format!(
621                "startup cancelled during {stage}"
622            )));
623        }
624        Ok(())
625    }
626
627    fn persist<T>(
628        &self,
629        stage: &'static str,
630        operation: impl FnOnce() -> Result<T, NodeError>,
631    ) -> Result<T, NodeError> {
632        *self.lock_stage() = stage;
633        let _permit = self
634            .mutation_admission
635            .lock()
636            .unwrap_or_else(std::sync::PoisonError::into_inner);
637        if self.cancelled.load(Ordering::Acquire)
638            || self
639                .deadline
640                .get()
641                .is_some_and(|deadline| Instant::now() >= *deadline)
642        {
643            return Err(NodeError::Unavailable(format!(
644                "startup cancelled before {stage}"
645            )));
646        }
647        operation()
648    }
649
650    fn cancellation_flag(&self) -> &AtomicBool {
651        self.cancelled.as_ref()
652    }
653
654    fn lock_stage(&self) -> std::sync::MutexGuard<'_, &'static str> {
655        self.stage
656            .lock()
657            .unwrap_or_else(std::sync::PoisonError::into_inner)
658    }
659}
660
661#[derive(Clone, Debug, Eq, PartialEq)]
662pub struct InMemoryLogPeer {
663    entries: Vec<LogEntry>,
664    anchor: Option<RecoveryAnchor>,
665}
666
667impl InMemoryLogPeer {
668    pub fn new(mut entries: Vec<LogEntry>) -> Self {
669        entries.sort_by_key(|entry| entry.index);
670        Self {
671            entries,
672            anchor: None,
673        }
674    }
675
676    pub fn with_anchor(mut entries: Vec<LogEntry>, anchor: RecoveryAnchor) -> Self {
677        entries.sort_by_key(|entry| entry.index);
678        Self {
679            entries,
680            anchor: Some(anchor),
681        }
682    }
683}
684
685impl LogPeer for InMemoryLogPeer {
686    fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
687        if let Some(anchor) = &self.anchor {
688            if request.from_index <= anchor.compacted().index() {
689                return Err(FetchLogError::SnapshotRequired {
690                    anchor: Box::new(anchor.clone()),
691                });
692            }
693        }
694        let entries = self
695            .entries
696            .iter()
697            .filter(|entry| entry.index >= request.from_index)
698            .take(request.max_entries as usize)
699            .cloned()
700            .collect();
701        let last_index = self
702            .entries
703            .last()
704            .map(|entry| entry.index)
705            .or_else(|| {
706                self.anchor
707                    .as_ref()
708                    .map(|anchor| anchor.compacted().index())
709            })
710            .unwrap_or(0);
711        Ok(FetchLogResponse {
712            entries,
713            last_index,
714        })
715    }
716}
717
718#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
719struct RecorderWire<T> {
720    version: u16,
721    body: T,
722}
723
724#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
725#[serde(tag = "status", content = "body")]
726enum RecorderV2Result<T> {
727    Ok(T),
728    Rejected(RejectReason),
729    Error(String),
730}
731
732#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
733struct StoreCommandV2 {
734    cluster_id: String,
735    epoch: u64,
736    config_id: u64,
737    config_digest: LogHash,
738    command_hash: LogHash,
739    command: StoredCommand,
740}
741
742#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
743struct FetchCommandV2 {
744    cluster_id: String,
745    epoch: u64,
746    config_id: u64,
747    config_digest: LogHash,
748    command_hash: LogHash,
749}
750
751#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
752struct InspectProofV2 {
753    slot: u64,
754}
755
756#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
757struct InstallProofV2 {
758    proof: DecisionProof,
759    members: Vec<String>,
760}
761
762#[derive(Clone, Debug)]
763pub struct HttpRecorderClient {
764    base_url: String,
765    local_node_id: String,
766    peer_token: String,
767    recovery_generation: u64,
768    client: Arc<std::sync::OnceLock<reqwest::blocking::Client>>,
769}
770
771impl HttpRecorderClient {
772    pub fn new(
773        base_url: impl Into<String>,
774        local_node_id: impl Into<String>,
775        peer_token: impl Into<String>,
776    ) -> Result<Self, ConfigError> {
777        Self::new_with_recovery_generation(base_url, local_node_id, peer_token, 1)
778    }
779
780    pub fn new_with_recovery_generation(
781        base_url: impl Into<String>,
782        local_node_id: impl Into<String>,
783        peer_token: impl Into<String>,
784        recovery_generation: u64,
785    ) -> Result<Self, ConfigError> {
786        validate_recovery_generation(recovery_generation)?;
787        let peer = PeerConfig::new(local_node_id, base_url, peer_token)?;
788        Ok(Self {
789            base_url: peer.base_url,
790            local_node_id: peer.node_id,
791            peer_token: peer.token,
792            recovery_generation,
793            client: Arc::new(std::sync::OnceLock::new()),
794        })
795    }
796
797    pub fn with_recovery_generation(
798        mut self,
799        recovery_generation: u64,
800    ) -> Result<Self, ConfigError> {
801        validate_recovery_generation(recovery_generation)?;
802        self.recovery_generation = recovery_generation;
803        Ok(self)
804    }
805
806    fn url(&self, path: &str) -> String {
807        format!("{}{}", self.base_url, path)
808    }
809
810    fn client(&self) -> rhiza_quepaxa::Result<&reqwest::blocking::Client> {
811        if self.client.get().is_none() {
812            let client = reqwest::blocking::Client::builder()
813                .connect_timeout(HTTP_CONNECT_TIMEOUT)
814                .timeout(HTTP_REQUEST_TIMEOUT)
815                .build()
816                .map_err(|error| rhiza_quepaxa::Error::Io(error.to_string()))?;
817            let _ = self.client.set(client);
818        }
819        self.client
820            .get()
821            .ok_or_else(|| rhiza_quepaxa::Error::Io("HTTP client initialization failed".into()))
822    }
823
824    fn post_v2<T, U>(&self, path: &str, body: T) -> rhiza_quepaxa::Result<U>
825    where
826        T: serde::Serialize,
827        U: serde::de::DeserializeOwned,
828    {
829        self.post_v2_with_timeout(path, body, HTTP_REQUEST_TIMEOUT)
830    }
831
832    fn post_v2_with_timeout<T, U>(
833        &self,
834        path: &str,
835        body: T,
836        timeout: Duration,
837    ) -> rhiza_quepaxa::Result<U>
838    where
839        T: serde::Serialize,
840        U: serde::de::DeserializeOwned,
841    {
842        let response = self
843            .client()?
844            .post(self.url(path))
845            .timeout(timeout)
846            .header(VERSION_HEADER, RECORDER_PROTOCOL_VERSION)
847            .header(NODE_ID_HEADER, &self.local_node_id)
848            .header(
849                RECOVERY_GENERATION_HEADER,
850                self.recovery_generation.to_string(),
851            )
852            .bearer_auth(&self.peer_token)
853            .json(&RecorderWire {
854                version: RECORDER_WIRE_VERSION,
855                body,
856            })
857            .send()
858            .map_err(|error| rhiza_quepaxa::Error::Io(error.to_string()))?;
859        let status = response.status();
860        let wire = response
861            .json::<RecorderWire<RecorderV2Result<U>>>()
862            .map_err(|error| rhiza_quepaxa::Error::Decode(error.to_string()))?;
863        if wire.version != RECORDER_WIRE_VERSION {
864            return Err(rhiza_quepaxa::Error::Decode(
865                "recorder wire version mismatch".into(),
866            ));
867        }
868        match wire.body {
869            RecorderV2Result::Ok(value) if status.is_success() => Ok(value),
870            RecorderV2Result::Ok(_) => Err(rhiza_quepaxa::Error::Io(format!(
871                "recorder rpc returned HTTP {status}"
872            ))),
873            RecorderV2Result::Rejected(reason) => Err(rhiza_quepaxa::Error::Rejected(reason)),
874            RecorderV2Result::Error(message) => Err(rhiza_quepaxa::Error::Io(message)),
875        }
876    }
877}
878
879impl RecorderRpc for HttpRecorderClient {
880    fn recorder_id(&self) -> rhiza_quepaxa::Result<String> {
881        self.post_v2(RECORDER_IDENTITY_PATH, ())
882    }
883
884    fn store_command_for(
885        &self,
886        cluster_id: String,
887        epoch: u64,
888        config_id: u64,
889        config_digest: LogHash,
890        command_hash: LogHash,
891        command: StoredCommand,
892    ) -> rhiza_quepaxa::Result<()> {
893        self.post_v2(
894            RECORDER_STORE_COMMAND_PATH,
895            StoreCommandV2 {
896                cluster_id,
897                epoch,
898                config_id,
899                config_digest,
900                command_hash,
901                command,
902            },
903        )
904    }
905
906    fn fetch_command_for(
907        &self,
908        cluster_id: String,
909        epoch: u64,
910        config_id: u64,
911        config_digest: LogHash,
912        command_hash: LogHash,
913    ) -> rhiza_quepaxa::Result<Option<StoredCommand>> {
914        self.post_v2(
915            RECORDER_FETCH_COMMAND_PATH,
916            FetchCommandV2 {
917                cluster_id,
918                epoch,
919                config_id,
920                config_digest,
921                command_hash,
922            },
923        )
924    }
925
926    fn record(&self, request: RecordRequest) -> rhiza_quepaxa::Result<RecordSummary> {
927        self.post_v2_with_timeout(RECORDER_RECORD_PATH, request, QUORUM_RECORD_REQUEST_TIMEOUT)
928            .map_err(map_quorum_record_transport_error)
929    }
930
931    fn install_decision_proof(
932        &self,
933        proof: DecisionProof,
934        membership: &Membership,
935    ) -> rhiza_quepaxa::Result<()> {
936        self.post_v2(
937            RECORDER_INSTALL_PROOF_PATH,
938            InstallProofV2 {
939                proof,
940                members: membership.members().to_vec(),
941            },
942        )
943    }
944
945    fn inspect_decision_proof(&self, slot: u64) -> rhiza_quepaxa::Result<Option<DecisionProof>> {
946        self.post_v2(RECORDER_INSPECT_PROOF_PATH, InspectProofV2 { slot })
947    }
948
949    fn inspect_record_summary(&self, slot: u64) -> rhiza_quepaxa::Result<Option<RecordSummary>> {
950        self.post_v2(RECORDER_INSPECT_RECORD_PATH, InspectProofV2 { slot })
951    }
952
953    fn supports_context_read_fence(&self) -> bool {
954        true
955    }
956
957    fn observe_read_fence(
958        &self,
959        request: ReadFenceRequest,
960    ) -> rhiza_quepaxa::Result<ReadFenceObservation> {
961        self.post_v2_with_timeout(
962            RECORDER_READ_FENCE_PATH,
963            request,
964            READ_FENCE_REQUEST_TIMEOUT,
965        )
966    }
967}
968
969#[derive(Clone, Debug)]
970pub struct HttpLogPeer {
971    base_url: String,
972    local_node_id: String,
973    peer_token: String,
974    recovery_generation: u64,
975    client: Arc<std::sync::OnceLock<reqwest::blocking::Client>>,
976}
977
978impl HttpLogPeer {
979    pub fn new(
980        base_url: impl Into<String>,
981        local_node_id: impl Into<String>,
982        peer_token: impl Into<String>,
983    ) -> Result<Self, ConfigError> {
984        Self::new_with_recovery_generation(base_url, local_node_id, peer_token, 1)
985    }
986
987    pub fn new_with_recovery_generation(
988        base_url: impl Into<String>,
989        local_node_id: impl Into<String>,
990        peer_token: impl Into<String>,
991        recovery_generation: u64,
992    ) -> Result<Self, ConfigError> {
993        validate_recovery_generation(recovery_generation)?;
994        let peer = PeerConfig::new(local_node_id, base_url, peer_token)?;
995        Ok(Self {
996            base_url: peer.base_url,
997            local_node_id: peer.node_id,
998            peer_token: peer.token,
999            recovery_generation,
1000            client: Arc::new(std::sync::OnceLock::new()),
1001        })
1002    }
1003
1004    pub fn with_recovery_generation(
1005        mut self,
1006        recovery_generation: u64,
1007    ) -> Result<Self, ConfigError> {
1008        validate_recovery_generation(recovery_generation)?;
1009        self.recovery_generation = recovery_generation;
1010        Ok(self)
1011    }
1012
1013    fn url(&self, path: &str) -> String {
1014        format!("{}{}", self.base_url, path)
1015    }
1016
1017    fn client(&self) -> Result<&reqwest::blocking::Client, FetchLogError> {
1018        if self.client.get().is_none() {
1019            let client = reqwest::blocking::Client::builder()
1020                .connect_timeout(HTTP_CONNECT_TIMEOUT)
1021                .timeout(HTTP_REQUEST_TIMEOUT)
1022                .build()
1023                .map_err(|error| FetchLogError::Transport {
1024                    message: error.to_string(),
1025                })?;
1026            let _ = self.client.set(client);
1027        }
1028        self.client.get().ok_or_else(|| FetchLogError::Transport {
1029            message: "HTTP client initialization failed".into(),
1030        })
1031    }
1032}
1033
1034impl LogPeer for HttpLogPeer {
1035    fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
1036        let response = self
1037            .client()?
1038            .post(self.url(LOG_FETCH_PATH))
1039            .header(VERSION_HEADER, PROTOCOL_VERSION)
1040            .header(NODE_ID_HEADER, &self.local_node_id)
1041            .header(
1042                RECOVERY_GENERATION_HEADER,
1043                self.recovery_generation.to_string(),
1044            )
1045            .bearer_auth(&self.peer_token)
1046            .json(&request)
1047            .send()
1048            .map_err(|err| FetchLogError::Transport {
1049                message: err.to_string(),
1050            })?;
1051        let status = response.status();
1052        if !status.is_success() {
1053            return match response.json::<FetchLogHttpResponse>() {
1054                Ok(FetchLogHttpResponse::Failed(error)) => Err(error),
1055                Ok(FetchLogHttpResponse::Fetched(_)) | Err(_) => Err(FetchLogError::Transport {
1056                    message: format!("log rpc returned HTTP {status}"),
1057                }),
1058            };
1059        }
1060        match response
1061            .json::<FetchLogHttpResponse>()
1062            .map_err(|err| FetchLogError::Decode {
1063                message: err.to_string(),
1064            })? {
1065            FetchLogHttpResponse::Fetched(response) => Ok(response),
1066            FetchLogHttpResponse::Failed(error) => Err(error),
1067        }
1068    }
1069}
1070
1071#[derive(Clone)]
1072struct RecorderRouteState<R> {
1073    recorder: R,
1074    peers: Vec<PeerConfig>,
1075}
1076
1077#[derive(Clone)]
1078struct AuthenticatedPeer(String);
1079
1080#[derive(Clone)]
1081struct LogRouteState<P> {
1082    peer: P,
1083}
1084
1085#[derive(Clone)]
1086struct NodeRouteState {
1087    runtime: Arc<NodeRuntime>,
1088    coordinator: Option<Arc<CheckpointCoordinator>>,
1089    write_operations: Arc<tokio::sync::Mutex<HashMap<String, WriteOperation>>>,
1090    writer: tokio::sync::mpsc::Sender<QueuedWrite>,
1091}
1092
1093#[derive(Clone)]
1094struct WriteOperation {
1095    payload: Vec<u8>,
1096    result: tokio::sync::watch::Receiver<Option<WriteOperationResult>>,
1097}
1098
1099#[derive(Clone)]
1100enum WriteOperationResult {
1101    Runtime(Result<ClientWriteResponse, NodeError>),
1102    DurabilityUnavailable,
1103}
1104
1105#[derive(Clone)]
1106enum ClientWriteResponse {
1107    #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1108    Unavailable,
1109    #[cfg(feature = "sql")]
1110    KeyValue(WriteResponse),
1111    #[cfg(feature = "sql")]
1112    Sql(SqlExecuteResponse),
1113    #[cfg(feature = "graph")]
1114    Graph(GraphMutationOutcome),
1115    #[cfg(feature = "kv")]
1116    Kv(KvMutationOutcome),
1117}
1118
1119struct QueuedWrite {
1120    request_id: String,
1121    payload: Vec<u8>,
1122    operation: QueuedOperation,
1123    permit: Arc<tokio::sync::OwnedSemaphorePermit>,
1124    sender: tokio::sync::watch::Sender<Option<WriteOperationResult>>,
1125}
1126
1127enum QueuedOperation {
1128    #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1129    Unavailable,
1130    #[cfg(feature = "sql")]
1131    KeyValue { key: String, value: String },
1132    #[cfg(feature = "sql")]
1133    Sql(SqlCommand),
1134    #[cfg(feature = "graph")]
1135    Graph(GraphCommandV1),
1136    #[cfg(feature = "kv")]
1137    Kv(KvCommandV1),
1138}
1139
1140struct RuntimeBatchMember {
1141    #[cfg(feature = "sql")]
1142    request_id: String,
1143    payload: Vec<u8>,
1144    operation: QueuedOperation,
1145}
1146
1147#[cfg(feature = "sql")]
1148type SqlGroupCommitResult = Result<Vec<Result<ClientWriteResponse, NodeError>>, NodeError>;
1149
1150#[cfg(feature = "sql")]
1151struct SqlGroupCommitJob {
1152    member_count: usize,
1153    encoded_bytes: usize,
1154    members: Mutex<Option<Vec<RuntimeBatchMember>>>,
1155    result: Mutex<Option<SqlGroupCommitResult>>,
1156    changed: Condvar,
1157}
1158
1159#[cfg(feature = "sql")]
1160impl SqlGroupCommitJob {
1161    fn new(members: Vec<RuntimeBatchMember>) -> Self {
1162        Self {
1163            member_count: members.len(),
1164            encoded_bytes: members.iter().fold(0_usize, |bytes, member| {
1165                bytes.saturating_add(member.payload.len())
1166            }),
1167            members: Mutex::new(Some(members)),
1168            result: Mutex::new(None),
1169            changed: Condvar::new(),
1170        }
1171    }
1172
1173    fn take_members(&self) -> Result<Vec<RuntimeBatchMember>, NodeError> {
1174        self.members
1175            .lock()
1176            .unwrap_or_else(std::sync::PoisonError::into_inner)
1177            .take()
1178            .ok_or_else(|| {
1179                NodeError::Invariant("SQL group commit job members were already consumed".into())
1180            })
1181    }
1182
1183    fn publish(&self, result: SqlGroupCommitResult) {
1184        let mut slot = self
1185            .result
1186            .lock()
1187            .unwrap_or_else(std::sync::PoisonError::into_inner);
1188        if slot.is_none() {
1189            *slot = Some(result);
1190            self.changed.notify_all();
1191        }
1192    }
1193
1194    fn wait(&self, cancelled: &AtomicBool) -> SqlGroupCommitResult {
1195        let mut result = self
1196            .result
1197            .lock()
1198            .unwrap_or_else(std::sync::PoisonError::into_inner);
1199        loop {
1200            if let Some(result) = result.take() {
1201                return result;
1202            }
1203            if cancelled.load(Ordering::Acquire) {
1204                return Err(NodeError::Unavailable(
1205                    "SQL group commit cancelled during shutdown".into(),
1206                ));
1207            }
1208            result = self
1209                .changed
1210                .wait(result)
1211                .unwrap_or_else(std::sync::PoisonError::into_inner);
1212        }
1213    }
1214}
1215
1216#[cfg(feature = "sql")]
1217struct SqlGroupCommitQueue {
1218    capacity: usize,
1219    state: Mutex<SqlGroupCommitQueueState>,
1220    changed: Condvar,
1221}
1222
1223#[cfg(feature = "sql")]
1224#[derive(Default)]
1225struct SqlGroupCommitQueueState {
1226    pending: VecDeque<Arc<SqlGroupCommitJob>>,
1227    pending_encoded_bytes: usize,
1228    leader_active: bool,
1229}
1230
1231#[cfg(feature = "sql")]
1232impl SqlGroupCommitQueue {
1233    fn new(capacity: usize) -> Self {
1234        Self {
1235            capacity,
1236            state: Mutex::new(SqlGroupCommitQueueState::default()),
1237            changed: Condvar::new(),
1238        }
1239    }
1240
1241    fn enqueue(
1242        &self,
1243        members: Vec<RuntimeBatchMember>,
1244        cancelled: &AtomicBool,
1245    ) -> Result<(Arc<SqlGroupCommitJob>, bool), NodeError> {
1246        if cancelled.load(Ordering::Acquire) {
1247            return Err(NodeError::Unavailable(
1248                "SQL group commit is unavailable during shutdown".into(),
1249            ));
1250        }
1251        let job = Arc::new(SqlGroupCommitJob::new(members));
1252        if job.encoded_bytes > MAX_COMMAND_BYTES {
1253            return Err(NodeError::ResourceExhausted(format!(
1254                "SQL group commit call exceeds {MAX_COMMAND_BYTES} encoded bytes"
1255            )));
1256        }
1257        let mut state = self
1258            .state
1259            .lock()
1260            .unwrap_or_else(std::sync::PoisonError::into_inner);
1261        if state.pending.len() >= self.capacity {
1262            return Err(NodeError::ResourceExhausted(format!(
1263                "SQL group commit queue is full (capacity {})",
1264                self.capacity
1265            )));
1266        }
1267        let pending_encoded_bytes = state
1268            .pending_encoded_bytes
1269            .saturating_add(job.encoded_bytes);
1270        if pending_encoded_bytes > MAX_SQL_GROUP_COMMIT_PENDING_BYTES {
1271            return Err(NodeError::ResourceExhausted(format!(
1272                "SQL group commit queue exceeds {MAX_SQL_GROUP_COMMIT_PENDING_BYTES} pending encoded bytes"
1273            )));
1274        }
1275        state.pending_encoded_bytes = pending_encoded_bytes;
1276        state.pending.push_back(Arc::clone(&job));
1277        self.changed.notify_all();
1278        let leader = !state.leader_active;
1279        if leader {
1280            state.leader_active = true;
1281        }
1282        Ok((job, leader))
1283    }
1284
1285    fn drain_next_group(&self) -> Option<Vec<Arc<SqlGroupCommitJob>>> {
1286        let mut state = self
1287            .state
1288            .lock()
1289            .unwrap_or_else(std::sync::PoisonError::into_inner);
1290        if state.pending.is_empty() {
1291            state.leader_active = false;
1292            return None;
1293        }
1294        let mut member_count = 0_usize;
1295        let mut encoded_bytes = 0_usize;
1296        let mut jobs = Vec::new();
1297        while let Some(job) = state.pending.front() {
1298            let next_count = member_count.saturating_add(job.member_count);
1299            let next_encoded_bytes = encoded_bytes.saturating_add(job.encoded_bytes);
1300            if !jobs.is_empty()
1301                && (next_count > MAX_SQL_WRITE_BATCH_MEMBERS
1302                    || next_encoded_bytes > MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES)
1303            {
1304                break;
1305            }
1306            let job = state.pending.pop_front().expect("front job exists");
1307            member_count = next_count;
1308            encoded_bytes = next_encoded_bytes;
1309            state.pending_encoded_bytes = state
1310                .pending_encoded_bytes
1311                .checked_sub(job.encoded_bytes)
1312                .expect("queued SQL byte reservation covers every pending job");
1313            jobs.push(job);
1314        }
1315        Some(jobs)
1316    }
1317
1318    fn collect_until_full_or_timeout(&self, timeout: Duration) -> bool {
1319        let mut state = self
1320            .state
1321            .lock()
1322            .unwrap_or_else(std::sync::PoisonError::into_inner);
1323        if state.pending.is_empty() {
1324            state.leader_active = false;
1325            return false;
1326        }
1327        let hard_deadline = Instant::now() + timeout.saturating_mul(4);
1328        loop {
1329            let member_count = state
1330                .pending
1331                .iter()
1332                .fold(0_usize, |count, job| count.saturating_add(job.member_count));
1333            if member_count >= MAX_SQL_WRITE_BATCH_MEMBERS
1334                || state.pending_encoded_bytes >= MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES
1335            {
1336                return true;
1337            }
1338            let remaining = hard_deadline.saturating_duration_since(Instant::now());
1339            if remaining.is_zero() {
1340                return true;
1341            }
1342            let observed_calls = state.pending.len();
1343            let (next, quiet) = self
1344                .changed
1345                .wait_timeout_while(state, timeout.min(remaining), |state| {
1346                    state.pending.len() == observed_calls
1347                        && state
1348                            .pending
1349                            .iter()
1350                            .fold(0_usize, |count, job| count.saturating_add(job.member_count))
1351                            < MAX_SQL_WRITE_BATCH_MEMBERS
1352                        && state.pending_encoded_bytes < MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES
1353                })
1354                .unwrap_or_else(std::sync::PoisonError::into_inner);
1355            state = next;
1356            if quiet.timed_out() {
1357                return true;
1358            }
1359        }
1360    }
1361
1362    fn fail_pending(&self, error: NodeError) {
1363        let jobs = {
1364            let mut state = self
1365                .state
1366                .lock()
1367                .unwrap_or_else(std::sync::PoisonError::into_inner);
1368            state.leader_active = false;
1369            state.pending_encoded_bytes = 0;
1370            state.pending.drain(..).collect::<Vec<_>>()
1371        };
1372        for job in jobs {
1373            job.publish(Err(error.clone()));
1374        }
1375    }
1376
1377    #[cfg(test)]
1378    fn wait_for_pending_calls(&self, expected: usize, timeout: Duration) {
1379        let state = self
1380            .state
1381            .lock()
1382            .unwrap_or_else(std::sync::PoisonError::into_inner);
1383        let (state, timed_out) = self
1384            .changed
1385            .wait_timeout_while(state, timeout, |state| state.pending.len() != expected)
1386            .unwrap_or_else(std::sync::PoisonError::into_inner);
1387        assert!(
1388            !timed_out.timed_out(),
1389            "expected {expected} pending SQL group commit calls, got {}",
1390            state.pending.len()
1391        );
1392    }
1393}
1394
1395#[cfg(feature = "kv")]
1396type KvGroupCommitResult = Result<Vec<Result<ClientWriteResponse, NodeError>>, NodeError>;
1397
1398#[cfg(feature = "kv")]
1399struct KvGroupCommitJob {
1400    member_count: usize,
1401    encoded_bytes: usize,
1402    members: Mutex<Option<Vec<RuntimeBatchMember>>>,
1403    result: Mutex<Option<KvGroupCommitResult>>,
1404    changed: Condvar,
1405}
1406
1407#[cfg(feature = "kv")]
1408impl KvGroupCommitJob {
1409    fn new(members: Vec<RuntimeBatchMember>) -> Self {
1410        Self {
1411            member_count: members.len(),
1412            encoded_bytes: members.iter().fold(0_usize, |bytes, member| {
1413                bytes.saturating_add(member.payload.len())
1414            }),
1415            members: Mutex::new(Some(members)),
1416            result: Mutex::new(None),
1417            changed: Condvar::new(),
1418        }
1419    }
1420
1421    fn take_members(&self) -> Result<Vec<RuntimeBatchMember>, NodeError> {
1422        self.members
1423            .lock()
1424            .unwrap_or_else(std::sync::PoisonError::into_inner)
1425            .take()
1426            .ok_or_else(|| {
1427                NodeError::Invariant("KV group commit job members were already consumed".into())
1428            })
1429    }
1430
1431    fn publish(&self, result: KvGroupCommitResult) {
1432        let mut slot = self
1433            .result
1434            .lock()
1435            .unwrap_or_else(std::sync::PoisonError::into_inner);
1436        if slot.is_none() {
1437            *slot = Some(result);
1438            self.changed.notify_all();
1439        }
1440    }
1441
1442    fn wait(&self, cancelled: &AtomicBool) -> KvGroupCommitResult {
1443        let mut result = self
1444            .result
1445            .lock()
1446            .unwrap_or_else(std::sync::PoisonError::into_inner);
1447        loop {
1448            if let Some(result) = result.take() {
1449                return result;
1450            }
1451            if cancelled.load(Ordering::Acquire) {
1452                return Err(NodeError::Unavailable(
1453                    "KV group commit cancelled during shutdown".into(),
1454                ));
1455            }
1456            result = self
1457                .changed
1458                .wait(result)
1459                .unwrap_or_else(std::sync::PoisonError::into_inner);
1460        }
1461    }
1462}
1463
1464#[cfg(feature = "kv")]
1465struct KvGroupCommitQueue {
1466    state: Mutex<KvGroupCommitQueueState>,
1467    changed: Condvar,
1468}
1469
1470#[cfg(feature = "kv")]
1471#[derive(Default)]
1472struct KvGroupCommitQueueState {
1473    pending: VecDeque<Arc<KvGroupCommitJob>>,
1474    pending_encoded_bytes: usize,
1475    leader_active: bool,
1476}
1477
1478#[cfg(feature = "kv")]
1479impl KvGroupCommitQueue {
1480    fn new() -> Self {
1481        Self {
1482            state: Mutex::new(KvGroupCommitQueueState::default()),
1483            changed: Condvar::new(),
1484        }
1485    }
1486
1487    fn enqueue(
1488        &self,
1489        members: Vec<RuntimeBatchMember>,
1490        cancelled: &AtomicBool,
1491    ) -> Result<(Arc<KvGroupCommitJob>, bool), NodeError> {
1492        if cancelled.load(Ordering::Acquire) {
1493            return Err(NodeError::Unavailable(
1494                "KV group commit is unavailable during shutdown".into(),
1495            ));
1496        }
1497        let job = Arc::new(KvGroupCommitJob::new(members));
1498        if job.member_count == 0 || job.member_count > MAX_KV_BATCH_MEMBERS {
1499            return Err(NodeError::InvalidRequest(format!(
1500                "KV group commit call must contain 1..={MAX_KV_BATCH_MEMBERS} members"
1501            )));
1502        }
1503        if job.encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES {
1504            return Err(NodeError::ResourceExhausted(format!(
1505                "KV group commit call exceeds {MAX_KV_GROUP_COMMIT_PENDING_BYTES} encoded bytes"
1506            )));
1507        }
1508        let mut state = self
1509            .state
1510            .lock()
1511            .unwrap_or_else(std::sync::PoisonError::into_inner);
1512        if state.pending.len() >= KV_GROUP_COMMIT_QUEUE_CAPACITY {
1513            return Err(NodeError::ResourceExhausted(format!(
1514                "KV group commit queue is full (capacity {KV_GROUP_COMMIT_QUEUE_CAPACITY})"
1515            )));
1516        }
1517        let pending_encoded_bytes = state
1518            .pending_encoded_bytes
1519            .saturating_add(job.encoded_bytes);
1520        if pending_encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES {
1521            return Err(NodeError::ResourceExhausted(format!(
1522                "KV group commit queue exceeds {MAX_KV_GROUP_COMMIT_PENDING_BYTES} pending encoded bytes"
1523            )));
1524        }
1525        state.pending_encoded_bytes = pending_encoded_bytes;
1526        state.pending.push_back(Arc::clone(&job));
1527        self.changed.notify_all();
1528        let leader = !state.leader_active;
1529        if leader {
1530            state.leader_active = true;
1531        }
1532        Ok((job, leader))
1533    }
1534
1535    fn drain_next_group(&self) -> Option<Vec<Arc<KvGroupCommitJob>>> {
1536        let mut state = self
1537            .state
1538            .lock()
1539            .unwrap_or_else(std::sync::PoisonError::into_inner);
1540        if state.pending.is_empty() {
1541            state.leader_active = false;
1542            return None;
1543        }
1544        let mut member_count = 0_usize;
1545        let mut encoded_bytes = 0_usize;
1546        let mut jobs = Vec::new();
1547        while let Some(job) = state.pending.front() {
1548            let next_count = member_count.saturating_add(job.member_count);
1549            let next_encoded_bytes = encoded_bytes.saturating_add(job.encoded_bytes);
1550            if !jobs.is_empty()
1551                && (next_count > MAX_KV_GROUP_COMMIT_MEMBERS
1552                    || next_encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES)
1553            {
1554                break;
1555            }
1556            let job = state.pending.pop_front().expect("front job exists");
1557            member_count = next_count;
1558            encoded_bytes = next_encoded_bytes;
1559            state.pending_encoded_bytes = state
1560                .pending_encoded_bytes
1561                .checked_sub(job.encoded_bytes)
1562                .expect("queued KV byte reservation covers every pending job");
1563            jobs.push(job);
1564        }
1565        Some(jobs)
1566    }
1567
1568    fn collect_until_full_or_timeout(&self, timeout: Duration) -> bool {
1569        let mut state = self
1570            .state
1571            .lock()
1572            .unwrap_or_else(std::sync::PoisonError::into_inner);
1573        if state.pending.is_empty() {
1574            state.leader_active = false;
1575            return false;
1576        }
1577        let hard_deadline = Instant::now() + timeout.saturating_mul(4);
1578        loop {
1579            let member_count = state
1580                .pending
1581                .iter()
1582                .fold(0_usize, |count, job| count.saturating_add(job.member_count));
1583            if member_count >= MAX_KV_GROUP_COMMIT_MEMBERS
1584                || state.pending_encoded_bytes >= MAX_KV_GROUP_COMMIT_PENDING_BYTES
1585            {
1586                return true;
1587            }
1588            let remaining = hard_deadline.saturating_duration_since(Instant::now());
1589            if remaining.is_zero() {
1590                return true;
1591            }
1592            let observed_calls = state.pending.len();
1593            let (next, quiet) = self
1594                .changed
1595                .wait_timeout_while(state, timeout.min(remaining), |state| {
1596                    state.pending.len() == observed_calls
1597                        && state
1598                            .pending
1599                            .iter()
1600                            .fold(0_usize, |count, job| count.saturating_add(job.member_count))
1601                            < MAX_KV_GROUP_COMMIT_MEMBERS
1602                        && state.pending_encoded_bytes < MAX_KV_GROUP_COMMIT_PENDING_BYTES
1603                })
1604                .unwrap_or_else(std::sync::PoisonError::into_inner);
1605            state = next;
1606            if quiet.timed_out() {
1607                return true;
1608            }
1609        }
1610    }
1611
1612    fn fail_pending(&self, error: NodeError) {
1613        let jobs = {
1614            let mut state = self
1615                .state
1616                .lock()
1617                .unwrap_or_else(std::sync::PoisonError::into_inner);
1618            state.leader_active = false;
1619            state.pending_encoded_bytes = 0;
1620            state.pending.drain(..).collect::<Vec<_>>()
1621        };
1622        for job in jobs {
1623            job.publish(Err(error.clone()));
1624        }
1625    }
1626
1627    #[cfg(test)]
1628    fn wait_for_pending_calls(&self, expected: usize, timeout: Duration) {
1629        let state = self
1630            .state
1631            .lock()
1632            .unwrap_or_else(std::sync::PoisonError::into_inner);
1633        let (state, timed_out) = self
1634            .changed
1635            .wait_timeout_while(state, timeout, |state| state.pending.len() != expected)
1636            .unwrap_or_else(std::sync::PoisonError::into_inner);
1637        assert!(
1638            !timed_out.timed_out(),
1639            "expected {expected} pending KV group commit calls, got {}",
1640            state.pending.len()
1641        );
1642    }
1643}
1644
1645#[cfg(any(feature = "graph", feature = "kv"))]
1646fn classify_pending_request(
1647    canonical_by_request: &mut HashMap<String, usize>,
1648    members: &[RuntimeBatchMember],
1649    index: usize,
1650    request_id: &str,
1651) -> Result<Option<usize>, NodeError> {
1652    let Some(&canonical) = canonical_by_request.get(request_id) else {
1653        canonical_by_request.insert(request_id.to_owned(), index);
1654        return Ok(None);
1655    };
1656    if members[canonical].payload == members[index].payload {
1657        Ok(Some(canonical))
1658    } else {
1659        Err(NodeError::InvalidRequest(format!(
1660            "request id {request_id:?} was reused with another command in the same writer batch"
1661        )))
1662    }
1663}
1664
1665impl ClientWriteResponse {
1666    fn applied_index(&self) -> LogIndex {
1667        match self {
1668            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1669            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
1670            #[cfg(feature = "sql")]
1671            Self::KeyValue(response) => response.applied_index,
1672            #[cfg(feature = "sql")]
1673            Self::Sql(response) => response.applied_index,
1674            #[cfg(feature = "graph")]
1675            Self::Graph(response) => response.applied_index(),
1676            #[cfg(feature = "kv")]
1677            Self::Kv(response) => response.applied_index(),
1678        }
1679    }
1680}
1681
1682#[cfg(feature = "sql")]
1683#[derive(Clone)]
1684pub struct NodeService {
1685    runtime: Arc<NodeRuntime>,
1686    coordinator: Option<Arc<CheckpointCoordinator>>,
1687    #[cfg(feature = "sql")]
1688    sql_reads_in_flight: Arc<AtomicUsize>,
1689}
1690
1691#[cfg(feature = "sql")]
1692struct SqlReadActivity {
1693    active: Arc<AtomicUsize>,
1694}
1695
1696#[cfg(feature = "sql")]
1697impl SqlReadActivity {
1698    fn enter(active: &Arc<AtomicUsize>) -> (Self, bool) {
1699        let previous = active.fetch_add(1, Ordering::Relaxed);
1700        debug_assert_ne!(previous, usize::MAX);
1701        (
1702            Self {
1703                active: active.clone(),
1704            },
1705            previous == 0,
1706        )
1707    }
1708}
1709
1710#[cfg(feature = "sql")]
1711impl Drop for SqlReadActivity {
1712    fn drop(&mut self) {
1713        let previous = self.active.fetch_sub(1, Ordering::Relaxed);
1714        debug_assert!(previous > 0);
1715    }
1716}
1717
1718#[cfg(feature = "sql")]
1719impl NodeService {
1720    pub fn new(runtime: Arc<NodeRuntime>, coordinator: Option<Arc<CheckpointCoordinator>>) -> Self {
1721        Self {
1722            runtime,
1723            coordinator,
1724            #[cfg(feature = "sql")]
1725            sql_reads_in_flight: Arc::new(AtomicUsize::new(0)),
1726        }
1727    }
1728
1729    #[cfg(feature = "sql")]
1730    pub async fn put(
1731        &self,
1732        request_id: &str,
1733        key: &str,
1734        value: &str,
1735    ) -> Result<WriteResponse, NodeError> {
1736        self.write(WriteRequest {
1737            request_id: request_id.into(),
1738            key: key.into(),
1739            value: value.into(),
1740        })
1741        .await
1742    }
1743
1744    #[cfg(feature = "sql")]
1745    pub async fn write(&self, request: WriteRequest) -> Result<WriteResponse, NodeError> {
1746        self.write_allowed()?;
1747        let runtime = self.runtime.clone();
1748        let response = tokio::task::spawn_blocking(move || {
1749            runtime.write(&request.request_id, &request.key, &request.value)
1750        })
1751        .await
1752        .map_err(node_service_task_error)??;
1753        self.confirm_committed(response.applied_index).await?;
1754        Ok(response)
1755    }
1756
1757    #[cfg(feature = "sql")]
1758    pub async fn execute_sql(&self, command: SqlCommand) -> Result<SqlExecuteResponse, NodeError> {
1759        self.write_allowed()?;
1760        let runtime = self.runtime.clone();
1761        let response =
1762            tokio::task::spawn_blocking(move || runtime.execute_sql_with_results(command))
1763                .await
1764                .map_err(node_service_task_error)??;
1765        self.confirm_committed(response.applied_index).await?;
1766        Ok(response)
1767    }
1768
1769    #[cfg(feature = "sql")]
1770    pub async fn read(
1771        &self,
1772        key: &str,
1773        consistency: ReadConsistency,
1774    ) -> Result<ReadResponse, NodeError> {
1775        let runtime = self.runtime.clone();
1776        let key = key.to_owned();
1777        self.run_sql_read_operation(consistency, move || runtime.read(&key, consistency))
1778            .await
1779            .map_err(node_service_task_error)?
1780    }
1781
1782    #[cfg(feature = "sql")]
1783    pub async fn query(
1784        &self,
1785        statement: SqlStatement,
1786        consistency: ReadConsistency,
1787        max_rows: u32,
1788    ) -> Result<SqlQueryResponse, NodeError> {
1789        let runtime = self.runtime.clone();
1790        self.run_sql_read_operation(consistency, move || {
1791            runtime.query_sql(&statement, consistency, max_rows)
1792        })
1793        .await
1794        .map_err(node_service_task_error)?
1795    }
1796
1797    #[cfg(feature = "sql")]
1798    async fn run_sql_read_operation<F, T>(
1799        &self,
1800        consistency: ReadConsistency,
1801        operation: F,
1802    ) -> Result<T, tokio::task::JoinError>
1803    where
1804        F: FnOnce() -> T + Send + 'static,
1805        T: Send + 'static,
1806    {
1807        if consistency == ReadConsistency::ReadBarrier {
1808            return run_read_operation(consistency, operation).await;
1809        }
1810        let (activity, sole_read) = SqlReadActivity::enter(&self.sql_reads_in_flight);
1811        let operation = move || {
1812            let _activity = activity;
1813            operation()
1814        };
1815        if sole_read {
1816            run_read_operation(consistency, operation).await
1817        } else {
1818            tokio::task::spawn_blocking(operation).await
1819        }
1820    }
1821
1822    #[cfg(feature = "sql")]
1823    fn write_allowed(&self) -> Result<(), NodeError> {
1824        self.coordinator
1825            .as_ref()
1826            .map_or(Ok(()), |coordinator| coordinator.write_allowed())
1827            .map_err(|error| NodeError::Unavailable(error.to_string()))
1828    }
1829
1830    #[cfg(feature = "sql")]
1831    async fn confirm_committed(&self, index: LogIndex) -> Result<(), NodeError> {
1832        confirm_write_durability(self.runtime.as_ref(), self.coordinator.as_deref(), index)
1833            .await
1834            .map_err(|error| NodeError::Unavailable(error.to_string()))
1835    }
1836}
1837
1838#[cfg(feature = "sql")]
1839fn node_service_task_error(error: tokio::task::JoinError) -> NodeError {
1840    NodeError::Fatal(format!("node service task failed: {error}"))
1841}
1842
1843#[doc(hidden)]
1844pub async fn run_read_operation<F, T>(
1845    consistency: ReadConsistency,
1846    operation: F,
1847) -> Result<T, tokio::task::JoinError>
1848where
1849    F: FnOnce() -> T + Send + 'static,
1850    T: Send + 'static,
1851{
1852    if consistency != ReadConsistency::ReadBarrier
1853        && matches!(
1854            tokio::runtime::Handle::current().runtime_flavor(),
1855            tokio::runtime::RuntimeFlavor::MultiThread
1856        )
1857    {
1858        Ok(tokio::task::block_in_place(operation))
1859    } else {
1860        tokio::task::spawn_blocking(operation).await
1861    }
1862}
1863
1864#[derive(Clone)]
1865struct PeerGateState {
1866    peers: Vec<PeerConfig>,
1867    recovery_generation: u64,
1868    protocol_version: &'static str,
1869    slots: Arc<tokio::sync::Semaphore>,
1870}
1871
1872#[derive(Clone)]
1873struct ClientGateState {
1874    runtime: Arc<NodeRuntime>,
1875    slots: Arc<tokio::sync::Semaphore>,
1876    coordinator: Option<Arc<CheckpointCoordinator>>,
1877}
1878
1879#[derive(Clone)]
1880struct RuntimeLogPeer {
1881    runtime: Arc<NodeRuntime>,
1882}
1883
1884impl LogPeer for RuntimeLogPeer {
1885    fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
1886        self.runtime.fetch_log(request)
1887    }
1888}
1889
1890fn fetch_runtime_log(
1891    runtime: &NodeRuntime,
1892    request: FetchLogRequest,
1893) -> Result<FetchLogResponse, FetchLogError> {
1894    if request.from_index == 0 || request.max_entries > MAX_FETCH_ENTRIES {
1895        return Err(FetchLogError::InvalidRequest {
1896            message: "invalid fetch bounds".into(),
1897        });
1898    }
1899    let state = runtime
1900        .log_store
1901        .logical_state()
1902        .map_err(|error| FetchLogError::Transport {
1903            message: error.to_string(),
1904        })?;
1905    if let Some(anchor) = state.anchor {
1906        if request.from_index <= anchor.compacted().index() {
1907            return Err(FetchLogError::SnapshotRequired {
1908                anchor: Box::new(anchor),
1909            });
1910        }
1911    }
1912    let last_index = state.tip.map_or(0, |tip| tip.index());
1913    if request.max_entries == 0 || request.from_index > last_index {
1914        return Ok(FetchLogResponse {
1915            entries: Vec::new(),
1916            last_index,
1917        });
1918    }
1919    let end = request
1920        .from_index
1921        .saturating_add(u64::from(request.max_entries) - 1)
1922        .min(last_index);
1923    let range = IndexRange::new(request.from_index, end).map_err(|error| {
1924        FetchLogError::InvalidRequest {
1925            message: error.to_string(),
1926        }
1927    })?;
1928    let entries =
1929        runtime
1930            .log_store
1931            .read_range(range)
1932            .map_err(|error| FetchLogError::Transport {
1933                message: error.to_string(),
1934            })?;
1935    Ok(FetchLogResponse {
1936        entries,
1937        last_index,
1938    })
1939}
1940
1941pub fn recorder_router<R, P>(recorder: R, peers: P) -> Router
1942where
1943    R: RecorderRpc + Clone + Send + Sync + 'static,
1944    P: Into<Vec<PeerConfig>>,
1945{
1946    recorder_router_for_generation(recorder, peers, 1)
1947}
1948
1949pub fn recorder_router_for_generation<R, P>(
1950    recorder: R,
1951    peers: P,
1952    recovery_generation: u64,
1953) -> Router
1954where
1955    R: RecorderRpc + Clone + Send + Sync + 'static,
1956    P: Into<Vec<PeerConfig>>,
1957{
1958    recorder_routes(
1959        recorder,
1960        peers.into(),
1961        recovery_generation,
1962        Arc::new(tokio::sync::Semaphore::new(DEFAULT_PEER_CONCURRENCY)),
1963    )
1964    .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
1965}
1966
1967pub fn log_peer_router<P, C>(peer: P, peers: C) -> Router
1968where
1969    P: LogPeer + Clone + Send + Sync + 'static,
1970    C: Into<Vec<PeerConfig>>,
1971{
1972    log_peer_router_for_generation(peer, peers, 1)
1973}
1974
1975pub fn log_peer_router_for_generation<P, C>(peer: P, peers: C, recovery_generation: u64) -> Router
1976where
1977    P: LogPeer + Clone + Send + Sync + 'static,
1978    C: Into<Vec<PeerConfig>>,
1979{
1980    log_routes(
1981        peer,
1982        peers.into(),
1983        recovery_generation,
1984        Arc::new(tokio::sync::Semaphore::new(DEFAULT_PEER_CONCURRENCY)),
1985    )
1986    .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
1987}
1988
1989pub fn node_rpc_router<R, P, C>(recorder: R, peer: P, peers: C) -> Router
1990where
1991    R: RecorderRpc + Clone + Send + Sync + 'static,
1992    P: LogPeer + Clone + Send + Sync + 'static,
1993    C: Into<Vec<PeerConfig>>,
1994{
1995    node_rpc_router_for_generation(recorder, peer, peers, 1)
1996}
1997
1998pub fn node_rpc_router_for_generation<R, P, C>(
1999    recorder: R,
2000    peer: P,
2001    peers: C,
2002    recovery_generation: u64,
2003) -> Router
2004where
2005    R: RecorderRpc + Clone + Send + Sync + 'static,
2006    P: LogPeer + Clone + Send + Sync + 'static,
2007    C: Into<Vec<PeerConfig>>,
2008{
2009    node_rpc_router_with_limits_for_generation(
2010        recorder,
2011        peer,
2012        peers,
2013        DEFAULT_PEER_CONCURRENCY,
2014        DEFAULT_PEER_CONCURRENCY,
2015        recovery_generation,
2016    )
2017}
2018
2019pub fn node_rpc_router_with_limits<R, P, C>(
2020    recorder: R,
2021    peer: P,
2022    peers: C,
2023    recorder_concurrency: usize,
2024    log_concurrency: usize,
2025) -> Router
2026where
2027    R: RecorderRpc + Clone + Send + Sync + 'static,
2028    P: LogPeer + Clone + Send + Sync + 'static,
2029    C: Into<Vec<PeerConfig>>,
2030{
2031    node_rpc_router_with_limits_for_generation(
2032        recorder,
2033        peer,
2034        peers,
2035        recorder_concurrency,
2036        log_concurrency,
2037        1,
2038    )
2039}
2040
2041pub fn node_rpc_router_with_limits_for_generation<R, P, C>(
2042    recorder: R,
2043    peer: P,
2044    peers: C,
2045    recorder_concurrency: usize,
2046    log_concurrency: usize,
2047    recovery_generation: u64,
2048) -> Router
2049where
2050    R: RecorderRpc + Clone + Send + Sync + 'static,
2051    P: LogPeer + Clone + Send + Sync + 'static,
2052    C: Into<Vec<PeerConfig>>,
2053{
2054    let peers = peers.into();
2055    let recorder_slots = Arc::new(tokio::sync::Semaphore::new(recorder_concurrency));
2056    let log_slots = Arc::new(tokio::sync::Semaphore::new(log_concurrency));
2057    recorder_routes(recorder, peers.clone(), recovery_generation, recorder_slots)
2058        .merge(log_routes(peer, peers, recovery_generation, log_slots))
2059        .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
2060}
2061
2062pub fn node_router<R>(runtime: Arc<NodeRuntime>, recorder: R) -> Router
2063where
2064    R: RecorderRpc + Clone + Send + Sync + 'static,
2065{
2066    node_router_with_limits(
2067        runtime,
2068        recorder,
2069        DEFAULT_CLIENT_CONCURRENCY,
2070        DEFAULT_PEER_CONCURRENCY,
2071    )
2072}
2073
2074pub fn node_router_with_limits<R>(
2075    runtime: Arc<NodeRuntime>,
2076    recorder: R,
2077    client_concurrency: usize,
2078    peer_concurrency: usize,
2079) -> Router
2080where
2081    R: RecorderRpc + Clone + Send + Sync + 'static,
2082{
2083    node_router_with_optional_checkpoint(
2084        runtime,
2085        recorder,
2086        None,
2087        client_concurrency,
2088        peer_concurrency,
2089    )
2090}
2091
2092pub fn node_router_with_checkpoint<R>(
2093    runtime: Arc<NodeRuntime>,
2094    recorder: R,
2095    coordinator: Arc<CheckpointCoordinator>,
2096) -> Router
2097where
2098    R: RecorderRpc + Clone + Send + Sync + 'static,
2099{
2100    node_router_with_checkpoint_and_limits(
2101        runtime,
2102        recorder,
2103        coordinator,
2104        DEFAULT_CLIENT_CONCURRENCY,
2105        DEFAULT_PEER_CONCURRENCY,
2106    )
2107}
2108
2109pub fn node_router_with_checkpoint_and_limits<R>(
2110    runtime: Arc<NodeRuntime>,
2111    recorder: R,
2112    coordinator: Arc<CheckpointCoordinator>,
2113    client_concurrency: usize,
2114    peer_concurrency: usize,
2115) -> Router
2116where
2117    R: RecorderRpc + Clone + Send + Sync + 'static,
2118{
2119    node_router_with_optional_checkpoint(
2120        runtime,
2121        recorder,
2122        Some(coordinator),
2123        client_concurrency,
2124        peer_concurrency,
2125    )
2126}
2127
2128fn node_router_with_optional_checkpoint<R>(
2129    runtime: Arc<NodeRuntime>,
2130    recorder: R,
2131    coordinator: Option<Arc<CheckpointCoordinator>>,
2132    client_concurrency: usize,
2133    peer_concurrency: usize,
2134) -> Router
2135where
2136    R: RecorderRpc + Clone + Send + Sync + 'static,
2137{
2138    let peers = runtime.config.peers.clone();
2139    let recovery_generation = runtime.config.recovery_generation();
2140    let client_slots = Arc::new(tokio::sync::Semaphore::new(client_concurrency));
2141    let recorder_slots = Arc::new(tokio::sync::Semaphore::new(peer_concurrency));
2142    let log_slots = Arc::new(tokio::sync::Semaphore::new(peer_concurrency));
2143    let write_operations = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
2144    let (writer, writer_receiver) = tokio::sync::mpsc::channel(client_concurrency.max(1));
2145    tokio::spawn(writer_loop(
2146        Arc::downgrade(&runtime),
2147        coordinator.clone(),
2148        write_operations.clone(),
2149        writer_receiver,
2150        runtime.config.writer_batch_max,
2151        runtime.config.writer_batch_window,
2152    ));
2153    #[cfg(any(feature = "sql", feature = "graph", feature = "kv"))]
2154    let client_routes: Router = match runtime.config().execution_profile() {
2155        ExecutionProfile::Sqlite => {
2156            #[cfg(feature = "sql")]
2157            {
2158                Router::new()
2159                    .route(WRITE_PATH, post(handle_write))
2160                    .route(READ_PATH, post(handle_read))
2161                    .route(SQL_EXECUTE_PATH, post(handle_sql_execute))
2162                    .route(SQL_QUERY_PATH, post(handle_sql_query))
2163            }
2164            #[cfg(not(feature = "sql"))]
2165            unreachable!("SQL runtime cannot open without the sql feature")
2166        }
2167        ExecutionProfile::Graph => {
2168            #[cfg(feature = "graph")]
2169            {
2170                Router::new()
2171                    .route(GRAPH_PUT_DOCUMENT_PATH, post(handle_graph_put_document))
2172                    .route(
2173                        GRAPH_DELETE_DOCUMENT_PATH,
2174                        post(handle_graph_delete_document),
2175                    )
2176                    .route(GRAPH_GET_DOCUMENT_PATH, post(handle_graph_get_document))
2177                    .route(GRAPH_QUERY_PATH, post(handle_graph_query))
2178            }
2179            #[cfg(not(feature = "graph"))]
2180            unreachable!("graph runtime cannot open without the graph feature")
2181        }
2182        ExecutionProfile::Kv => {
2183            #[cfg(feature = "kv")]
2184            {
2185                Router::new()
2186                    .route(KV_PUT_PATH, post(handle_kv_put))
2187                    .route(KV_DELETE_PATH, post(handle_kv_delete))
2188                    .route(KV_GET_PATH, post(handle_kv_get))
2189                    .route(KV_SCAN_PATH, post(handle_kv_scan))
2190            }
2191            #[cfg(not(feature = "kv"))]
2192            unreachable!("KV runtime cannot open without the kv feature")
2193        }
2194    }
2195    .route_layer(middleware::from_fn_with_state(
2196        ClientGateState {
2197            runtime: runtime.clone(),
2198            slots: client_slots,
2199            coordinator: coordinator.clone(),
2200        },
2201        client_gate,
2202    ))
2203    .with_state(NodeRouteState {
2204        runtime: runtime.clone(),
2205        coordinator: coordinator.clone(),
2206        write_operations: write_operations.clone(),
2207        writer: writer.clone(),
2208    });
2209    #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
2210    let client_routes: Router = Router::new();
2211    let health_routes = Router::new()
2212        .route(LIVEZ_PATH, get(handle_livez))
2213        .route(READYZ_PATH, get(handle_readyz))
2214        .with_state(NodeRouteState {
2215            runtime: runtime.clone(),
2216            coordinator,
2217            write_operations,
2218            writer,
2219        });
2220    recorder_routes(recorder, peers.clone(), recovery_generation, recorder_slots)
2221        .merge(log_routes(
2222            RuntimeLogPeer { runtime },
2223            peers,
2224            recovery_generation,
2225            log_slots,
2226        ))
2227        .merge(client_routes)
2228        .merge(health_routes)
2229        .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
2230}
2231
2232fn recorder_routes<R>(
2233    recorder: R,
2234    peers: Vec<PeerConfig>,
2235    recovery_generation: u64,
2236    slots: Arc<tokio::sync::Semaphore>,
2237) -> Router
2238where
2239    R: RecorderRpc + Clone + Send + Sync + 'static,
2240{
2241    let recorder_peers = peers.clone();
2242    Router::new()
2243        .route(RECORDER_IDENTITY_PATH, post(handle_recorder_identity::<R>))
2244        .route(
2245            RECORDER_STORE_COMMAND_PATH,
2246            post(handle_recorder_store_command::<R>),
2247        )
2248        .route(
2249            RECORDER_FETCH_COMMAND_PATH,
2250            post(handle_recorder_fetch_command::<R>),
2251        )
2252        .route(
2253            RECORDER_INSPECT_PROOF_PATH,
2254            post(handle_recorder_inspect_proof::<R>),
2255        )
2256        .route(
2257            RECORDER_INSPECT_RECORD_PATH,
2258            post(handle_recorder_inspect_record::<R>),
2259        )
2260        .route(
2261            RECORDER_READ_FENCE_PATH,
2262            post(handle_recorder_read_fence::<R>),
2263        )
2264        .route(RECORDER_RECORD_PATH, post(handle_recorder_record::<R>))
2265        .route(
2266            RECORDER_INSTALL_PROOF_PATH,
2267            post(handle_recorder_install_proof::<R>),
2268        )
2269        .route_layer(middleware::from_fn_with_state(
2270            PeerGateState {
2271                peers,
2272                recovery_generation,
2273                protocol_version: RECORDER_PROTOCOL_VERSION,
2274                slots,
2275            },
2276            peer_gate,
2277        ))
2278        .with_state(RecorderRouteState {
2279            recorder,
2280            peers: recorder_peers,
2281        })
2282}
2283
2284fn log_routes<P>(
2285    peer: P,
2286    peers: Vec<PeerConfig>,
2287    recovery_generation: u64,
2288    slots: Arc<tokio::sync::Semaphore>,
2289) -> Router
2290where
2291    P: LogPeer + Clone + Send + Sync + 'static,
2292{
2293    Router::new()
2294        .route(LOG_FETCH_PATH, post(handle_fetch_log::<P>))
2295        .route_layer(middleware::from_fn_with_state(
2296            PeerGateState {
2297                peers,
2298                recovery_generation,
2299                protocol_version: PROTOCOL_VERSION,
2300                slots,
2301            },
2302            peer_gate,
2303        ))
2304        .with_state(LogRouteState { peer })
2305}
2306
2307async fn handle_recorder_identity<R>(
2308    State(state): State<RecorderRouteState<R>>,
2309    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2310    Json(request): Json<RecorderWire<()>>,
2311) -> Response
2312where
2313    R: RecorderRpc + Clone + Send + Sync + 'static,
2314{
2315    if request.version != RECORDER_WIRE_VERSION {
2316        return StatusCode::BAD_REQUEST.into_response();
2317    }
2318    let recorder = state.recorder;
2319    recorder_v2_response(
2320        tokio::task::spawn_blocking(move || {
2321            let _permit = permit;
2322            recorder.recorder_id()
2323        })
2324        .await,
2325    )
2326}
2327
2328async fn handle_recorder_store_command<R>(
2329    State(state): State<RecorderRouteState<R>>,
2330    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2331    Json(request): Json<RecorderWire<StoreCommandV2>>,
2332) -> Response
2333where
2334    R: RecorderRpc + Clone + Send + Sync + 'static,
2335{
2336    if request.version != RECORDER_WIRE_VERSION || !valid_recorder_command(&request.body.command) {
2337        return StatusCode::BAD_REQUEST.into_response();
2338    }
2339    let recorder = state.recorder;
2340    recorder_v2_response(
2341        tokio::task::spawn_blocking(move || {
2342            let _permit = permit;
2343            let body = request.body;
2344            recorder.store_command_for(
2345                body.cluster_id,
2346                body.epoch,
2347                body.config_id,
2348                body.config_digest,
2349                body.command_hash,
2350                body.command,
2351            )
2352        })
2353        .await,
2354    )
2355}
2356
2357async fn handle_recorder_fetch_command<R>(
2358    State(state): State<RecorderRouteState<R>>,
2359    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2360    Json(request): Json<RecorderWire<FetchCommandV2>>,
2361) -> Response
2362where
2363    R: RecorderRpc + Clone + Send + Sync + 'static,
2364{
2365    if request.version != RECORDER_WIRE_VERSION {
2366        return StatusCode::BAD_REQUEST.into_response();
2367    }
2368    let recorder = state.recorder;
2369    recorder_v2_response(
2370        tokio::task::spawn_blocking(move || {
2371            let _permit = permit;
2372            let body = request.body;
2373            recorder.fetch_command_for(
2374                body.cluster_id,
2375                body.epoch,
2376                body.config_id,
2377                body.config_digest,
2378                body.command_hash,
2379            )
2380        })
2381        .await,
2382    )
2383}
2384
2385async fn handle_recorder_inspect_proof<R>(
2386    State(state): State<RecorderRouteState<R>>,
2387    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2388    Json(request): Json<RecorderWire<InspectProofV2>>,
2389) -> Response
2390where
2391    R: RecorderRpc + Clone + Send + Sync + 'static,
2392{
2393    if request.version != RECORDER_WIRE_VERSION {
2394        return StatusCode::BAD_REQUEST.into_response();
2395    }
2396    let recorder = state.recorder;
2397    recorder_v2_response(
2398        tokio::task::spawn_blocking(move || {
2399            let _permit = permit;
2400            recorder.inspect_decision_proof(request.body.slot)
2401        })
2402        .await,
2403    )
2404}
2405
2406async fn handle_recorder_inspect_record<R>(
2407    State(state): State<RecorderRouteState<R>>,
2408    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2409    Json(request): Json<RecorderWire<InspectProofV2>>,
2410) -> Response
2411where
2412    R: RecorderRpc + Clone + Send + Sync + 'static,
2413{
2414    if request.version != RECORDER_WIRE_VERSION {
2415        return StatusCode::BAD_REQUEST.into_response();
2416    }
2417    let recorder = state.recorder;
2418    recorder_v2_response(
2419        tokio::task::spawn_blocking(move || {
2420            let _permit = permit;
2421            recorder.inspect_record_summary(request.body.slot)
2422        })
2423        .await,
2424    )
2425}
2426
2427async fn handle_recorder_read_fence<R>(
2428    State(state): State<RecorderRouteState<R>>,
2429    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2430    Json(request): Json<RecorderWire<ReadFenceRequest>>,
2431) -> Response
2432where
2433    R: RecorderRpc + Clone + Send + Sync + 'static,
2434{
2435    if request.version != RECORDER_WIRE_VERSION {
2436        return StatusCode::BAD_REQUEST.into_response();
2437    }
2438    let recorder = state.recorder;
2439    recorder_v2_response(
2440        tokio::task::spawn_blocking(move || {
2441            let _permit = permit;
2442            recorder.observe_read_fence(request.body)
2443        })
2444        .await,
2445    )
2446}
2447
2448async fn handle_recorder_record<R>(
2449    State(state): State<RecorderRouteState<R>>,
2450    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2451    Extension(authenticated_peer): Extension<AuthenticatedPeer>,
2452    Json(request): Json<RecorderWire<RecordRequest>>,
2453) -> Response
2454where
2455    R: RecorderRpc + Clone + Send + Sync + 'static,
2456{
2457    if request.version != RECORDER_WIRE_VERSION || !valid_recorder_record(&request.body) {
2458        return StatusCode::BAD_REQUEST.into_response();
2459    }
2460    if !authenticated_proposer_admitted(
2461        &authenticated_peer.0,
2462        &request.body.proposal.proposer_id,
2463        &state.peers,
2464    ) {
2465        return recorder_v2_response::<RecordSummary>(Ok(Err(rhiza_quepaxa::Error::Rejected(
2466            RejectReason::InvalidRequest,
2467        ))));
2468    }
2469    let recorder = state.recorder;
2470    recorder_v2_response(
2471        tokio::task::spawn_blocking(move || {
2472            let _permit = permit;
2473            recorder.record(request.body)
2474        })
2475        .await,
2476    )
2477}
2478
2479fn valid_recorder_command(command: &StoredCommand) -> bool {
2480    command.payload.len() <= MAX_COMMAND_BYTES
2481}
2482
2483fn valid_recorder_record(request: &RecordRequest) -> bool {
2484    !request.cluster_id.is_empty()
2485        && request.cluster_id.len() <= MAX_REQUEST_ID_BYTES
2486        && request.command.as_ref().is_none_or(valid_recorder_command)
2487}
2488
2489fn authenticated_proposer_admitted(
2490    authenticated_peer_id: &str,
2491    proposer_id: &str,
2492    peers: &[PeerConfig],
2493) -> bool {
2494    // Record requests carry config identity but not its membership. Configured peers are therefore
2495    // the transport identity authority for records and proofs until rebuilt after a transition.
2496    peers
2497        .iter()
2498        .any(|peer| peer.node_id == authenticated_peer_id)
2499        && peers.iter().any(|peer| peer.node_id == proposer_id)
2500}
2501
2502async fn handle_recorder_install_proof<R>(
2503    State(state): State<RecorderRouteState<R>>,
2504    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2505    Extension(authenticated_peer): Extension<AuthenticatedPeer>,
2506    Json(request): Json<RecorderWire<InstallProofV2>>,
2507) -> Response
2508where
2509    R: RecorderRpc + Clone + Send + Sync + 'static,
2510{
2511    if request.version != RECORDER_WIRE_VERSION {
2512        return StatusCode::BAD_REQUEST.into_response();
2513    }
2514    if !authenticated_proposer_admitted(
2515        &authenticated_peer.0,
2516        &request.body.proof.proposal().proposer_id,
2517        &state.peers,
2518    ) {
2519        return recorder_v2_response::<()>(Ok(Err(rhiza_quepaxa::Error::Rejected(
2520            RejectReason::InvalidRequest,
2521        ))));
2522    }
2523    let recorder = state.recorder;
2524    recorder_v2_response(
2525        tokio::task::spawn_blocking(move || {
2526            let _permit = permit;
2527            let membership = Membership::from_voters(request.body.members)?;
2528            recorder.install_decision_proof(request.body.proof, &membership)
2529        })
2530        .await,
2531    )
2532}
2533
2534fn recorder_v2_response<T: serde::Serialize>(
2535    result: Result<rhiza_quepaxa::Result<T>, tokio::task::JoinError>,
2536) -> Response {
2537    let (status, body) = match result {
2538        Ok(Ok(value)) => (StatusCode::OK, RecorderV2Result::Ok(value)),
2539        Ok(Err(rhiza_quepaxa::Error::Rejected(reason))) => {
2540            (StatusCode::CONFLICT, RecorderV2Result::Rejected(reason))
2541        }
2542        Ok(Err(error)) => (
2543            recorder_error_status(&error),
2544            RecorderV2Result::Error(error.to_string()),
2545        ),
2546        Err(error) => (
2547            StatusCode::INTERNAL_SERVER_ERROR,
2548            RecorderV2Result::Error(error.to_string()),
2549        ),
2550    };
2551    (
2552        status,
2553        Json(RecorderWire {
2554            version: RECORDER_WIRE_VERSION,
2555            body,
2556        }),
2557    )
2558        .into_response()
2559}
2560
2561async fn handle_fetch_log<P>(
2562    State(state): State<LogRouteState<P>>,
2563    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2564    Json(request): Json<FetchLogRequest>,
2565) -> Response
2566where
2567    P: LogPeer + Clone + Send + Sync + 'static,
2568{
2569    if request.from_index == 0 || request.max_entries > MAX_FETCH_ENTRIES {
2570        return StatusCode::BAD_REQUEST.into_response();
2571    }
2572    let peer = state.peer;
2573    let result = tokio::task::spawn_blocking(move || {
2574        let _permit = permit;
2575        peer.fetch_log(request)
2576    })
2577    .await;
2578    match result {
2579        Ok(Ok(response)) => Json(FetchLogHttpResponse::Fetched(response)).into_response(),
2580        Ok(Err(error)) => (
2581            fetch_log_error_status(&error),
2582            Json(FetchLogHttpResponse::Failed(error)),
2583        )
2584            .into_response(),
2585        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
2586    }
2587}
2588
2589#[cfg(feature = "sql")]
2590async fn handle_write(
2591    State(state): State<NodeRouteState>,
2592    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2593    request: Result<Json<WriteRequest>, JsonRejection>,
2594) -> Response {
2595    let request = match client_json(request) {
2596        Ok(request) => request,
2597        Err(response) => return response,
2598    };
2599    let payload = match canonical_put(&request.request_id, &request.key, &request.value) {
2600        Ok(payload) => payload,
2601        Err(error) => return node_error_response(error),
2602    };
2603    let request_id = request.request_id.clone();
2604    let operation = QueuedOperation::KeyValue {
2605        key: request.key,
2606        value: request.value,
2607    };
2608    coordinate_write(state, permit, request_id, payload, operation).await
2609}
2610
2611#[cfg(feature = "sql")]
2612async fn handle_sql_execute(
2613    State(state): State<NodeRouteState>,
2614    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2615    request: Result<Json<SqlExecuteRequest>, JsonRejection>,
2616) -> Response {
2617    let request = match client_json(request) {
2618        Ok(request) => request,
2619        Err(response) => return response,
2620    };
2621    if let Err(error) = validate_field(
2622        "request_id",
2623        &request.request_id,
2624        MAX_REQUEST_ID_BYTES,
2625        false,
2626    ) {
2627        return node_error_response(error);
2628    }
2629    let command = SqlCommand {
2630        request_id: request.request_id.clone(),
2631        statements: request.statements,
2632    };
2633    let payload = match encode_sql_command_with_index(&command) {
2634        Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
2635        Ok(_) => {
2636            return node_error_response(NodeError::InvalidRequest(format!(
2637                "command exceeds {MAX_COMMAND_BYTES} bytes"
2638            )))
2639        }
2640        Err(error) => return node_error_response(error),
2641    };
2642    let request_id = command.request_id.clone();
2643    coordinate_write(
2644        state,
2645        permit,
2646        request_id,
2647        payload,
2648        QueuedOperation::Sql(command),
2649    )
2650    .await
2651}
2652
2653async fn coordinate_write(
2654    state: NodeRouteState,
2655    permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2656    request_id: String,
2657    payload: Vec<u8>,
2658    operation: QueuedOperation,
2659) -> Response {
2660    let deadline = tokio::time::Instant::now() + CLIENT_WRITE_WAIT_TIMEOUT;
2661    let (mut receiver, queued) = {
2662        let mut operations = state.write_operations.lock().await;
2663        if let Some(operation) = operations.get(&request_id) {
2664            if operation.payload != payload {
2665                return client_error_response(
2666                    StatusCode::CONFLICT,
2667                    "request_conflict",
2668                    false,
2669                    "request id is already in flight with a different payload",
2670                    None,
2671                );
2672            }
2673            (operation.result.clone(), None)
2674        } else {
2675            let (sender, receiver) = tokio::sync::watch::channel(None);
2676            operations.insert(
2677                request_id.clone(),
2678                WriteOperation {
2679                    payload: payload.clone(),
2680                    result: receiver.clone(),
2681                },
2682            );
2683            (
2684                receiver,
2685                Some(QueuedWrite {
2686                    request_id: request_id.clone(),
2687                    payload,
2688                    operation,
2689                    permit,
2690                    sender,
2691                }),
2692            )
2693        }
2694    };
2695    if let Some(queued) = queued {
2696        match tokio::time::timeout_at(deadline, state.writer.send(queued)).await {
2697            Ok(Ok(())) => {}
2698            Ok(Err(_)) => {
2699                state.write_operations.lock().await.remove(&request_id);
2700                return client_error_response(
2701                    StatusCode::SERVICE_UNAVAILABLE,
2702                    "durability_unavailable",
2703                    true,
2704                    "writer queue is unavailable",
2705                    None,
2706                );
2707            }
2708            Err(_) => {
2709                state.write_operations.lock().await.remove(&request_id);
2710                return client_error_response(
2711                    StatusCode::SERVICE_UNAVAILABLE,
2712                    "write_timeout",
2713                    true,
2714                    "write did not enter the queue before the response deadline",
2715                    None,
2716                );
2717            }
2718        }
2719    }
2720    let wait = async {
2721        loop {
2722            if let Some(result) = receiver.borrow().clone() {
2723                return result;
2724            }
2725            if receiver.changed().await.is_err() {
2726                return WriteOperationResult::DurabilityUnavailable;
2727            }
2728        }
2729    };
2730    match tokio::time::timeout_at(deadline, wait).await {
2731        Ok(WriteOperationResult::Runtime(Ok(response))) => match response {
2732            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
2733            ClientWriteResponse::Unavailable => {
2734                unreachable!("no execution profiles are compiled in")
2735            }
2736            #[cfg(feature = "sql")]
2737            ClientWriteResponse::KeyValue(response) => Json(response).into_response(),
2738            #[cfg(feature = "sql")]
2739            ClientWriteResponse::Sql(response) => Json(response).into_response(),
2740            #[cfg(feature = "graph")]
2741            ClientWriteResponse::Graph(outcome) => {
2742                Json(graph_mutation_response(outcome)).into_response()
2743            }
2744            #[cfg(feature = "kv")]
2745            ClientWriteResponse::Kv(outcome) => Json(kv_mutation_response(outcome)).into_response(),
2746        },
2747        Ok(WriteOperationResult::Runtime(Err(error))) => node_error_response(error),
2748        Ok(WriteOperationResult::DurabilityUnavailable) => client_error_response(
2749            StatusCode::SERVICE_UNAVAILABLE,
2750            "durability_unavailable",
2751            true,
2752            "durability confirmation is unavailable",
2753            None,
2754        ),
2755        Err(_) => client_error_response(
2756            StatusCode::SERVICE_UNAVAILABLE,
2757            "write_timeout",
2758            true,
2759            "write did not complete before the response deadline",
2760            None,
2761        ),
2762    }
2763}
2764
2765async fn writer_loop(
2766    runtime: std::sync::Weak<NodeRuntime>,
2767    coordinator: Option<Arc<CheckpointCoordinator>>,
2768    write_operations: Arc<tokio::sync::Mutex<HashMap<String, WriteOperation>>>,
2769    mut receiver: tokio::sync::mpsc::Receiver<QueuedWrite>,
2770    batch_max: usize,
2771    batch_window: Duration,
2772) {
2773    while let Some(first) = receiver.recv().await {
2774        let mut queued = Vec::with_capacity(batch_max);
2775        queued.push(first);
2776        let deadline = tokio::time::Instant::now() + batch_window;
2777        while queued.len() < batch_max {
2778            match tokio::time::timeout_at(deadline, receiver.recv()).await {
2779                Ok(Some(next)) => queued.push(next),
2780                Ok(None) | Err(_) => break,
2781            }
2782        }
2783
2784        let mut dispatch = Vec::with_capacity(queued.len());
2785        let mut members = Vec::with_capacity(queued.len());
2786        for queued in queued {
2787            members.push(RuntimeBatchMember {
2788                #[cfg(feature = "sql")]
2789                request_id: queued.request_id.clone(),
2790                payload: queued.payload,
2791                operation: queued.operation,
2792            });
2793            dispatch.push((queued.request_id, queued.sender, queued.permit));
2794        }
2795
2796        let Some(runtime) = runtime.upgrade() else {
2797            for (request_id, sender, _permit) in dispatch {
2798                sender.send_replace(Some(WriteOperationResult::DurabilityUnavailable));
2799                write_operations.lock().await.remove(&request_id);
2800            }
2801            break;
2802        };
2803
2804        let blocking_runtime = runtime.clone();
2805        let results =
2806            tokio::task::spawn_blocking(move || blocking_runtime.execute_client_batch(members))
2807                .await
2808                .unwrap_or_else(|error| {
2809                    (0..dispatch.len())
2810                        .map(|_| {
2811                            Err(NodeError::Fatal(format!(
2812                                "writer batch task failed: {error}"
2813                            )))
2814                        })
2815                        .collect()
2816                });
2817        let dispatch = dispatch
2818            .into_iter()
2819            .map(|(request_id, sender, permit)| {
2820                drop(permit);
2821                (request_id, sender)
2822            })
2823            .collect::<Vec<_>>();
2824
2825        let committed_index = results
2826            .iter()
2827            .filter_map(|result| result.as_ref().ok().map(ClientWriteResponse::applied_index))
2828            .max();
2829        let durability_available = if let Some(index) = committed_index {
2830            confirm_write_durability(runtime.as_ref(), coordinator.as_deref(), index)
2831                .await
2832                .is_ok()
2833        } else {
2834            true
2835        };
2836
2837        for ((request_id, sender), result) in dispatch.into_iter().zip(results) {
2838            let delivered = if !durability_available && result.is_ok() {
2839                WriteOperationResult::DurabilityUnavailable
2840            } else {
2841                WriteOperationResult::Runtime(result)
2842            };
2843            sender.send_replace(Some(delivered));
2844            write_operations.lock().await.remove(&request_id);
2845        }
2846    }
2847}
2848
2849#[cfg(feature = "sql")]
2850async fn handle_sql_query(
2851    State(state): State<NodeRouteState>,
2852    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2853    request: Result<Json<SqlQueryRequest>, JsonRejection>,
2854) -> Response {
2855    let request = match client_json(request) {
2856        Ok(request) => request,
2857        Err(response) => return response,
2858    };
2859    let runtime = state.runtime;
2860    let consistency = request
2861        .consistency
2862        .unwrap_or(runtime.config.read_consistency());
2863    let max_rows = request.max_rows.unwrap_or(DEFAULT_SQL_MAX_ROWS);
2864    if max_rows == 0 || max_rows > MAX_SQL_MAX_ROWS {
2865        return node_error_response(NodeError::InvalidRequest(format!(
2866            "max_rows must be between 1 and {MAX_SQL_MAX_ROWS}"
2867        )));
2868    }
2869    let result = tokio::task::spawn_blocking(move || {
2870        let _permit = permit;
2871        runtime.query_sql(&request.statement, consistency, max_rows)
2872    })
2873    .await;
2874    match result {
2875        Ok(Ok(response)) => sql_query_http_response(response),
2876        Ok(Err(error)) => node_error_response(error),
2877        Err(error) => client_task_error(error),
2878    }
2879}
2880
2881#[cfg(feature = "sql")]
2882fn sql_query_http_response(response: SqlQueryResponse) -> Response {
2883    match serde_json::to_vec(&response) {
2884        Ok(encoded) if encoded.len() <= MAX_SQL_RESPONSE_BYTES => (
2885            [(axum::http::header::CONTENT_TYPE, "application/json")],
2886            encoded,
2887        )
2888            .into_response(),
2889        Ok(_) => node_error_response(NodeError::InvalidRequest(format!(
2890            "SQL response exceeds {MAX_SQL_RESPONSE_BYTES} bytes"
2891        ))),
2892        Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
2893    }
2894}
2895
2896#[cfg(feature = "graph")]
2897async fn handle_graph_put_document(
2898    State(state): State<NodeRouteState>,
2899    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2900    request: Result<Json<GraphPutDocumentRequest>, JsonRejection>,
2901) -> Response {
2902    let request = match client_json(request) {
2903        Ok(request) => request,
2904        Err(response) => return response,
2905    };
2906    let value = match GraphValueV1::try_from(request.value) {
2907        Ok(value) => value,
2908        Err(error) => return node_error_response(error),
2909    };
2910    let command = match GraphCommandV1::put_document(request.request_id, request.id, value) {
2911        Ok(command) => command,
2912        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2913    };
2914    execute_graph_mutation(state, permit, command).await
2915}
2916
2917#[cfg(feature = "graph")]
2918async fn handle_graph_delete_document(
2919    State(state): State<NodeRouteState>,
2920    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2921    request: Result<Json<GraphDeleteDocumentRequest>, JsonRejection>,
2922) -> Response {
2923    let request = match client_json(request) {
2924        Ok(request) => request,
2925        Err(response) => return response,
2926    };
2927    let command = match GraphCommandV1::delete_document(request.request_id, request.id) {
2928        Ok(command) => command,
2929        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2930    };
2931    execute_graph_mutation(state, permit, command).await
2932}
2933
2934#[cfg(feature = "graph")]
2935async fn execute_graph_mutation(
2936    state: NodeRouteState,
2937    permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2938    command: GraphCommandV1,
2939) -> Response {
2940    let payload = match encode_replicated_graph_command(&command) {
2941        Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
2942        Ok(_) => {
2943            return node_error_response(NodeError::InvalidRequest(format!(
2944                "command exceeds {MAX_COMMAND_BYTES} bytes"
2945            )))
2946        }
2947        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2948    };
2949    coordinate_write(
2950        state,
2951        permit,
2952        command.request_id().to_owned(),
2953        payload,
2954        QueuedOperation::Graph(command),
2955    )
2956    .await
2957}
2958
2959#[cfg(feature = "graph")]
2960async fn handle_graph_get_document(
2961    State(state): State<NodeRouteState>,
2962    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2963    request: Result<Json<GraphGetDocumentRequest>, JsonRejection>,
2964) -> Response {
2965    let request = match client_json(request) {
2966        Ok(request) => request,
2967        Err(response) => return response,
2968    };
2969    let runtime = state.runtime;
2970    let consistency = request
2971        .consistency
2972        .unwrap_or(runtime.config.read_consistency());
2973    let result = tokio::task::spawn_blocking(move || {
2974        let _permit = permit;
2975        runtime.get_graph_document(&request.id, consistency)
2976    })
2977    .await;
2978    match result {
2979        Ok(Ok(response)) => Json(GraphGetDocumentResponse {
2980            value: response.value.map(GraphValueDto::from),
2981            applied_index: response.applied_index,
2982            hash: response.hash,
2983        })
2984        .into_response(),
2985        Ok(Err(error)) => node_error_response(error),
2986        Err(error) => client_task_error(error),
2987    }
2988}
2989
2990#[cfg(feature = "graph")]
2991fn with_graph_client_permit<T>(
2992    permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2993    response_work: impl FnOnce() -> T,
2994) -> T {
2995    let result = response_work();
2996    drop(permit);
2997    result
2998}
2999
3000#[cfg(feature = "graph")]
3001async fn handle_graph_query(
3002    State(state): State<NodeRouteState>,
3003    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3004    request: Result<Json<GraphQueryRequest>, JsonRejection>,
3005) -> Response {
3006    let request = match client_json(request) {
3007        Ok(request) => request,
3008        Err(response) => return response,
3009    };
3010    let parameters = match request
3011        .statement
3012        .parameters
3013        .into_iter()
3014        .map(|(name, value)| GraphParameterValue::try_from(value).map(|value| (name, value)))
3015        .collect::<Result<BTreeMap<_, _>, _>>()
3016    {
3017        Ok(parameters) => parameters,
3018        Err(error) => return node_error_response(error),
3019    };
3020    let runtime = state.runtime;
3021    let consistency = request
3022        .consistency
3023        .unwrap_or(runtime.config.read_consistency());
3024    let max_rows = request.max_rows.unwrap_or(DEFAULT_GRAPH_MAX_ROWS);
3025    if max_rows == 0 || max_rows > MAX_GRAPH_MAX_ROWS {
3026        return node_error_response(NodeError::InvalidRequest(format!(
3027            "max_rows must be between 1 and {MAX_GRAPH_MAX_ROWS}"
3028        )));
3029    }
3030    let result = tokio::task::spawn_blocking(move || {
3031        runtime.query_graph(
3032            &request.statement.cypher,
3033            &parameters,
3034            consistency,
3035            max_rows,
3036        )
3037    })
3038    .await;
3039    with_graph_client_permit(permit, || match result {
3040        Ok(Ok(result)) => {
3041            let response = GraphQueryResponse::from(result);
3042            match serde_json::to_vec(&response) {
3043                Ok(encoded) if encoded.len() <= MAX_GRAPH_RESPONSE_BYTES => {
3044                    Json(response).into_response()
3045                }
3046                Ok(_) => node_error_response(NodeError::InvalidRequest(format!(
3047                    "graph response exceeds {MAX_GRAPH_RESPONSE_BYTES} bytes"
3048                ))),
3049                Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
3050            }
3051        }
3052        Ok(Err(error)) => node_error_response(error),
3053        Err(error) => client_task_error(error),
3054    })
3055}
3056
3057#[cfg(feature = "kv")]
3058async fn handle_kv_put(
3059    State(state): State<NodeRouteState>,
3060    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3061    request: Result<Json<KvPutRequest>, JsonRejection>,
3062) -> Response {
3063    let request = match client_json(request) {
3064        Ok(request) => request,
3065        Err(response) => return response,
3066    };
3067    let key = match decode_base64("key", &request.key) {
3068        Ok(value) => value,
3069        Err(error) => return node_error_response(error),
3070    };
3071    let value = match decode_base64("value", &request.value) {
3072        Ok(value) => value,
3073        Err(error) => return node_error_response(error),
3074    };
3075    let command = match KvCommandV1::put(request.request_id, key, value) {
3076        Ok(command) => command,
3077        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3078    };
3079    execute_kv_mutation(state, permit, command).await
3080}
3081
3082#[cfg(feature = "kv")]
3083async fn handle_kv_delete(
3084    State(state): State<NodeRouteState>,
3085    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3086    request: Result<Json<KvDeleteRequest>, JsonRejection>,
3087) -> Response {
3088    let request = match client_json(request) {
3089        Ok(request) => request,
3090        Err(response) => return response,
3091    };
3092    let key = match decode_base64("key", &request.key) {
3093        Ok(value) => value,
3094        Err(error) => return node_error_response(error),
3095    };
3096    let command = match KvCommandV1::delete(request.request_id, key) {
3097        Ok(command) => command,
3098        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3099    };
3100    execute_kv_mutation(state, permit, command).await
3101}
3102
3103#[cfg(feature = "kv")]
3104async fn execute_kv_mutation(
3105    state: NodeRouteState,
3106    permit: Arc<tokio::sync::OwnedSemaphorePermit>,
3107    command: KvCommandV1,
3108) -> Response {
3109    let payload = match encode_replicated_kv_command(&command) {
3110        Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
3111        Ok(_) => {
3112            return node_error_response(NodeError::InvalidRequest(format!(
3113                "command exceeds {MAX_COMMAND_BYTES} bytes"
3114            )))
3115        }
3116        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3117    };
3118    coordinate_write(
3119        state,
3120        permit,
3121        command.request_id().to_owned(),
3122        payload,
3123        QueuedOperation::Kv(command),
3124    )
3125    .await
3126}
3127
3128#[cfg(feature = "kv")]
3129async fn handle_kv_get(
3130    State(state): State<NodeRouteState>,
3131    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3132    request: Result<Json<KvGetRequest>, JsonRejection>,
3133) -> Response {
3134    let request = match client_json(request) {
3135        Ok(request) => request,
3136        Err(response) => return response,
3137    };
3138    let key = match decode_base64("key", &request.key) {
3139        Ok(value) => value,
3140        Err(error) => return node_error_response(error),
3141    };
3142    let runtime = state.runtime;
3143    let consistency = request
3144        .consistency
3145        .unwrap_or(runtime.config.read_consistency());
3146    let result = tokio::task::spawn_blocking(move || {
3147        let _permit = permit;
3148        runtime.get_kv(&key, consistency)
3149    })
3150    .await;
3151    match result {
3152        Ok(Ok(response)) => Json(KvGetResponse {
3153            value: response.value.as_deref().map(encode_base64),
3154            applied_index: response.applied_index,
3155            hash: response.hash,
3156        })
3157        .into_response(),
3158        Ok(Err(error)) => node_error_response(error),
3159        Err(error) => client_task_error(error),
3160    }
3161}
3162
3163#[cfg(feature = "kv")]
3164enum DecodedKvScan {
3165    Range {
3166        start: Vec<u8>,
3167        end: Option<Vec<u8>>,
3168    },
3169    Prefix(Vec<u8>),
3170}
3171
3172#[cfg(feature = "kv")]
3173async fn handle_kv_scan(
3174    State(state): State<NodeRouteState>,
3175    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3176    request: Result<Json<KvScanRequest>, JsonRejection>,
3177) -> Response {
3178    let request = match client_json(request) {
3179        Ok(request) => request,
3180        Err(response) => return response,
3181    };
3182    let limit = request
3183        .limit
3184        .unwrap_or(usize::try_from(DEFAULT_KV_SCAN_LIMIT).expect("u32 fits usize"));
3185    if limit == 0 || limit > MAX_KV_SCAN_ROWS {
3186        return node_error_response(NodeError::InvalidRequest(format!(
3187            "limit must be between 1 and {MAX_KV_SCAN_ROWS}"
3188        )));
3189    }
3190    let cursor = match request.cursor {
3191        Some(cursor) => match decode_base64("cursor", &cursor) {
3192            Ok(cursor) => Some(cursor),
3193            Err(error) => return node_error_response(error),
3194        },
3195        None => None,
3196    };
3197    let scan = match (request.prefix, request.start, request.end) {
3198        (Some(prefix), None, None) => match decode_base64("prefix", &prefix) {
3199            Ok(prefix) => DecodedKvScan::Prefix(prefix),
3200            Err(error) => return node_error_response(error),
3201        },
3202        (None, Some(start), end) => {
3203            let start = match decode_base64("start", &start) {
3204                Ok(start) => start,
3205                Err(error) => return node_error_response(error),
3206            };
3207            let end = match end {
3208                Some(end) => match decode_base64("end", &end) {
3209                    Ok(end) => Some(end),
3210                    Err(error) => return node_error_response(error),
3211                },
3212                None => None,
3213            };
3214            DecodedKvScan::Range { start, end }
3215        }
3216        _ => {
3217            return node_error_response(NodeError::InvalidRequest(
3218                "provide either prefix alone or start with optional end".into(),
3219            ))
3220        }
3221    };
3222    let runtime = state.runtime;
3223    let consistency = request
3224        .consistency
3225        .unwrap_or(runtime.config.read_consistency());
3226    let result = tokio::task::spawn_blocking(move || {
3227        let _permit = permit;
3228        match scan {
3229            DecodedKvScan::Range { start, end } => runtime.scan_kv_range(
3230                &start,
3231                end.as_deref(),
3232                limit,
3233                cursor.as_deref(),
3234                consistency,
3235            ),
3236            DecodedKvScan::Prefix(prefix) => {
3237                runtime.scan_kv_prefix(&prefix, limit, cursor.as_deref(), consistency)
3238            }
3239        }
3240    })
3241    .await;
3242    match result {
3243        Ok(Ok(result)) => {
3244            let response = KvScanResponse {
3245                entries: result
3246                    .rows()
3247                    .iter()
3248                    .map(|row| KvScanEntryDto {
3249                        key: encode_base64(row.key()),
3250                        value: encode_base64(row.value()),
3251                    })
3252                    .collect(),
3253                next_cursor: result.next_cursor().map(encode_base64),
3254                applied_index: result.tip().applied_index(),
3255                hash: result.tip().applied_hash(),
3256            };
3257            match serde_json::to_vec(&response) {
3258                Ok(encoded) if encoded.len() <= MAX_KV_SCAN_RESPONSE_BYTES => {
3259                    Json(response).into_response()
3260                }
3261                Ok(_) => node_error_response(NodeError::ResourceExhausted(format!(
3262                    "KV scan response exceeds {MAX_KV_SCAN_RESPONSE_BYTES} bytes"
3263                ))),
3264                Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
3265            }
3266        }
3267        Ok(Err(error)) => node_error_response(error),
3268        Err(error) => client_task_error(error),
3269    }
3270}
3271
3272#[cfg(feature = "sql")]
3273async fn handle_read(
3274    State(state): State<NodeRouteState>,
3275    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3276    request: Result<Json<ReadRequest>, JsonRejection>,
3277) -> Response {
3278    let request = match client_json(request) {
3279        Ok(request) => request,
3280        Err(response) => return response,
3281    };
3282    let runtime = state.runtime;
3283    let consistency = request
3284        .consistency
3285        .unwrap_or(runtime.config.read_consistency());
3286    let result = tokio::task::spawn_blocking(move || {
3287        let _permit = permit;
3288        runtime.read(&request.key, consistency)
3289    })
3290    .await;
3291    match result {
3292        Ok(Ok(response)) => Json(response).into_response(),
3293        Ok(Err(error)) => node_error_response(error),
3294        Err(error) => client_task_error(error),
3295    }
3296}
3297
3298async fn peer_gate(
3299    State(state): State<PeerGateState>,
3300    mut request: Request,
3301    next: Next,
3302) -> Response {
3303    if !recovery_generation_matches(request.headers(), state.recovery_generation) {
3304        return StatusCode::UNAUTHORIZED.into_response();
3305    }
3306    let Some(authenticated_peer) =
3307        authenticated_peer(request.headers(), &state.peers, state.protocol_version)
3308    else {
3309        return StatusCode::UNAUTHORIZED.into_response();
3310    };
3311    let permit = match state.slots.try_acquire_owned() {
3312        Ok(permit) => Arc::new(permit),
3313        Err(_) => return StatusCode::TOO_MANY_REQUESTS.into_response(),
3314    };
3315    request.extensions_mut().insert(permit);
3316    request
3317        .extensions_mut()
3318        .insert(AuthenticatedPeer(authenticated_peer));
3319    next.run(request).await
3320}
3321
3322async fn client_gate(
3323    State(state): State<ClientGateState>,
3324    mut request: Request,
3325    next: Next,
3326) -> Response {
3327    if !client_authenticated(request.headers(), state.runtime.config.client_token()) {
3328        return client_error_response(
3329            StatusCode::UNAUTHORIZED,
3330            "unauthorized",
3331            false,
3332            "client authentication failed",
3333            None,
3334        );
3335    }
3336    if let Some(response) = runtime_readiness_response(state.runtime.as_ref()) {
3337        return response;
3338    }
3339    if client_write_path(request.uri().path())
3340        && state
3341            .coordinator
3342            .as_ref()
3343            .is_some_and(|coordinator| coordinator.write_allowed().is_err())
3344    {
3345        return client_error_response(
3346            StatusCode::SERVICE_UNAVAILABLE,
3347            "writes_unavailable",
3348            true,
3349            "writes are temporarily unavailable",
3350            None,
3351        );
3352    }
3353    let permit = match state.slots.try_acquire_owned() {
3354        Ok(permit) => Arc::new(permit),
3355        Err(_) => {
3356            return client_error_response(
3357                StatusCode::TOO_MANY_REQUESTS,
3358                "overloaded",
3359                true,
3360                "client request capacity is exhausted",
3361                None,
3362            )
3363        }
3364    };
3365    request.extensions_mut().insert(permit);
3366    next.run(request).await
3367}
3368
3369fn client_write_path(path: &str) -> bool {
3370    #[cfg(feature = "sql")]
3371    if matches!(path, WRITE_PATH | SQL_EXECUTE_PATH) {
3372        return true;
3373    }
3374    #[cfg(feature = "graph")]
3375    if matches!(path, GRAPH_PUT_DOCUMENT_PATH | GRAPH_DELETE_DOCUMENT_PATH) {
3376        return true;
3377    }
3378    #[cfg(feature = "kv")]
3379    if matches!(path, KV_PUT_PATH | KV_DELETE_PATH) {
3380        return true;
3381    }
3382    false
3383}
3384
3385async fn handle_livez() -> StatusCode {
3386    StatusCode::OK
3387}
3388
3389async fn handle_readyz(State(state): State<NodeRouteState>) -> StatusCode {
3390    if state.runtime.is_ready()
3391        && state
3392            .runtime
3393            .configuration_state()
3394            .is_ok_and(|configuration| configuration.is_active())
3395        && state
3396            .coordinator
3397            .as_ref()
3398            .is_none_or(|coordinator| coordinator.health() == DurabilityHealth::Available)
3399    {
3400        StatusCode::OK
3401    } else {
3402        StatusCode::SERVICE_UNAVAILABLE
3403    }
3404}
3405
3406fn next_sync_flush_retry(current: Duration) -> Duration {
3407    current.saturating_mul(2).min(SYNC_FLUSH_RETRY_MAX)
3408}
3409
3410/// Confirms that a committed write has reached the configured durability boundary.
3411///
3412/// Synchronous archive I/O failures are retried with bounded backoff until the
3413/// archive recovers or the runtime begins shutdown.
3414pub async fn confirm_write_durability(
3415    runtime: &NodeRuntime,
3416    coordinator: Option<&CheckpointCoordinator>,
3417    index: LogIndex,
3418) -> Result<(), DurabilityError> {
3419    let Some(coordinator) = coordinator else {
3420        return Ok(());
3421    };
3422    coordinator.note_committed(index);
3423    if !matches!(coordinator.mode(), DurabilityMode::Sync) {
3424        return Ok(());
3425    }
3426
3427    let mut retry_delay = SYNC_FLUSH_RETRY_INITIAL;
3428    loop {
3429        if runtime.operation_cancelled.load(Ordering::Acquire) {
3430            return Err(DurabilityError::Unavailable);
3431        }
3432        match coordinator.flush_runtime(runtime, index).await {
3433            Ok(tip) if tip.index() >= index => return Ok(()),
3434            Ok(tip) => {
3435                return Err(DurabilityError::LocalLogGap {
3436                    expected: index,
3437                    actual: Some(tip.index()),
3438                })
3439            }
3440            Err(DurabilityError::Archive(_) | DurabilityError::Io(_)) => {
3441                let cancelled = runtime.operation_cancelled_notify.notified();
3442                tokio::pin!(cancelled);
3443                cancelled.as_mut().enable();
3444                if runtime.operation_cancelled.load(Ordering::Acquire) {
3445                    return Err(DurabilityError::Unavailable);
3446                }
3447                tokio::select! {
3448                    () = tokio::time::sleep(retry_delay) => {}
3449                    () = &mut cancelled => return Err(DurabilityError::Unavailable),
3450                }
3451                retry_delay = next_sync_flush_retry(retry_delay);
3452            }
3453            Err(error) => return Err(error),
3454        }
3455    }
3456}
3457
3458fn authenticated_peer(
3459    headers: &HeaderMap,
3460    peers: &[PeerConfig],
3461    protocol_version: &str,
3462) -> Option<String> {
3463    if header_text(headers, VERSION_HEADER) != Some(protocol_version) {
3464        return None;
3465    }
3466    let node_id = header_text(headers, NODE_ID_HEADER)?;
3467    let token = bearer_token(headers)?;
3468    peer_credentials_authenticated(node_id, token, peers).then(|| node_id.to_owned())
3469}
3470
3471fn peer_credentials_authenticated(node_id: &str, token: &str, peers: &[PeerConfig]) -> bool {
3472    peers
3473        .iter()
3474        .find(|peer| peer.node_id == node_id)
3475        .is_some_and(|peer| secrets_equal(peer.token.as_bytes(), token.as_bytes()))
3476}
3477
3478fn recovery_generation_matches(headers: &HeaderMap, expected: u64) -> bool {
3479    let expected = expected.to_string();
3480    header_text(headers, RECOVERY_GENERATION_HEADER) == Some(expected.as_str())
3481}
3482
3483fn client_authenticated(headers: &HeaderMap, expected_token: &str) -> bool {
3484    !expected_token.is_empty()
3485        && version_matches(headers)
3486        && bearer_token(headers)
3487            .is_some_and(|token| secrets_equal(expected_token.as_bytes(), token.as_bytes()))
3488}
3489
3490fn version_matches(headers: &HeaderMap) -> bool {
3491    header_text(headers, VERSION_HEADER) == Some(PROTOCOL_VERSION)
3492}
3493
3494fn header_text<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
3495    headers.get(name)?.to_str().ok()
3496}
3497
3498fn bearer_token(headers: &HeaderMap) -> Option<&str> {
3499    header_text(headers, "authorization")?.strip_prefix("Bearer ")
3500}
3501
3502fn secrets_equal(left: &[u8], right: &[u8]) -> bool {
3503    if left.len() != right.len() {
3504        return false;
3505    }
3506    left.iter()
3507        .zip(right)
3508        .fold(0_u8, |difference, (left, right)| {
3509            difference | (left ^ right)
3510        })
3511        == 0
3512}
3513
3514fn node_error_response(error: NodeError) -> Response {
3515    let (status, statement_index) = match &error {
3516        NodeError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, None),
3517        #[cfg(feature = "sql")]
3518        NodeError::InvalidSqlStatement {
3519            statement_index, ..
3520        } => (StatusCode::BAD_REQUEST, Some(*statement_index)),
3521        #[cfg(feature = "sql")]
3522        NodeError::RequestConflict(_) => (StatusCode::CONFLICT, None),
3523        NodeError::PreconditionFailed(_) => (StatusCode::CONFLICT, None),
3524        NodeError::SnapshotRequired(_)
3525        | NodeError::Unavailable(_)
3526        | NodeError::ResourceExhausted(_)
3527        | NodeError::ConfigurationTransition { .. }
3528        | NodeError::Contention(_)
3529        | NodeError::WinnerLimitExceeded => (StatusCode::SERVICE_UNAVAILABLE, None),
3530        NodeError::DataRootLocked(_)
3531        | NodeError::UnsupportedAckMode(_)
3532        | NodeError::ExecutionProfileMismatch { .. }
3533        | NodeError::Storage(_)
3534        | NodeError::Reconciliation(_)
3535        | NodeError::Invariant(_)
3536        | NodeError::Fatal(_) => (StatusCode::INTERNAL_SERVER_ERROR, None),
3537    };
3538    let classification = error.classification();
3539    if !matches!(&error, NodeError::Fatal(_)) {
3540        eprintln!(
3541            "node request failed (code={}, retryable={}): {}",
3542            classification.code(),
3543            classification.retryable(),
3544            escaped_error_detail(&error)
3545        );
3546    }
3547    client_error_response(
3548        status,
3549        classification.code(),
3550        classification.retryable(),
3551        node_error_message(status),
3552        statement_index,
3553    )
3554}
3555
3556fn node_error_message(status: StatusCode) -> &'static str {
3557    match status {
3558        StatusCode::BAD_REQUEST => "request could not be processed",
3559        StatusCode::CONFLICT => "request conflicts with current state",
3560        StatusCode::SERVICE_UNAVAILABLE => "service is temporarily unavailable",
3561        _ => "internal server error",
3562    }
3563}
3564
3565const MAX_ESCAPED_ERROR_DETAIL_BYTES: usize = 4 * 1024;
3566const ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER: &str = "...[truncated]";
3567
3568fn escaped_error_detail(error: &dyn fmt::Display) -> String {
3569    let detail = error.to_string();
3570    let mut escaped = String::with_capacity(detail.len().min(MAX_ESCAPED_ERROR_DETAIL_BYTES));
3571    for character in detail.chars() {
3572        let character_start = escaped.len();
3573        for escaped_character in character.escape_default() {
3574            if escaped.len()
3575                + escaped_character.len_utf8()
3576                + ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER.len()
3577                > MAX_ESCAPED_ERROR_DETAIL_BYTES
3578            {
3579                escaped.truncate(character_start);
3580                escaped.push_str(ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER);
3581                return escaped;
3582            }
3583            escaped.push(escaped_character);
3584        }
3585    }
3586    escaped
3587}
3588
3589#[allow(clippy::result_large_err)]
3590fn client_json<T>(request: Result<Json<T>, JsonRejection>) -> Result<T, Response> {
3591    request.map(|Json(request)| request).map_err(|rejection| {
3592        let status = rejection.status();
3593        eprintln!("invalid JSON request: {}", escaped_error_detail(&rejection));
3594        client_json_error_response(status)
3595    })
3596}
3597
3598fn client_json_error_response(status: StatusCode) -> Response {
3599    let code = if status == StatusCode::PAYLOAD_TOO_LARGE {
3600        "payload_too_large"
3601    } else {
3602        "invalid_json"
3603    };
3604    client_error_response(status, code, false, "request body is invalid", None)
3605}
3606
3607fn client_task_error(error: tokio::task::JoinError) -> Response {
3608    eprintln!("request task failed: {}", escaped_error_detail(&error));
3609    client_error_response(
3610        StatusCode::INTERNAL_SERVER_ERROR,
3611        "task_failed",
3612        false,
3613        "request task failed",
3614        None,
3615    )
3616}
3617
3618fn client_error_response(
3619    status: StatusCode,
3620    code: impl Into<String>,
3621    retryable: bool,
3622    message: impl Into<String>,
3623    statement_index: Option<usize>,
3624) -> Response {
3625    (
3626        status,
3627        Json(ClientErrorResponse {
3628            code: code.into(),
3629            retryable,
3630            message: message.into(),
3631            statement_index,
3632        }),
3633    )
3634        .into_response()
3635}
3636
3637pub fn install_successor_recorder(
3638    recorder: &RecorderFileStore,
3639    next_config_id: u64,
3640    membership: Membership,
3641    stop: &StopInformation,
3642) -> Result<rhiza_quepaxa::ConfigurationState, NodeError> {
3643    if stop.entry.config_id.checked_add(1) != Some(next_config_id) {
3644        return Err(NodeError::PreconditionFailed(
3645            "successor identity does not match the Stop proof".into(),
3646        ));
3647    }
3648    recorder
3649        .install_successor_from_proof(membership, &stop.proof)
3650        .map_err(|error| NodeError::Reconciliation(error.to_string()))
3651}
3652
3653pub fn recover_successor_recorder_after_checkpoint(
3654    recorder: &RecorderFileStore,
3655    config: &NodeConfig,
3656    next_config_id: u64,
3657    membership: Membership,
3658    stop: &StopInformation,
3659) -> Result<rhiza_quepaxa::ConfigurationState, NodeError> {
3660    let installed = install_successor_recorder(recorder, next_config_id, membership.clone(), stop)?;
3661    let log = FileLogStore::open_with_configuration(
3662        config.data_dir.join("consensus/log"),
3663        &config.cluster_id,
3664        config.epoch,
3665        config.log_initial_configuration.clone(),
3666    )
3667    .map_err(|error| NodeError::Storage(error.to_string()))?;
3668    let recovered_configuration = log
3669        .configuration_state()
3670        .map_err(|error| NodeError::Storage(error.to_string()))?;
3671    if !recovered_configuration.is_active() {
3672        return Ok(installed);
3673    }
3674    if recovered_configuration.config_id() != next_config_id
3675        || recovered_configuration.digest() != membership.digest()
3676    {
3677        return Err(NodeError::Reconciliation(
3678            "recovered successor qlog configuration does not match the target bundle".into(),
3679        ));
3680    }
3681    let tip = log
3682        .logical_state()
3683        .map_err(|error| NodeError::Storage(error.to_string()))?
3684        .tip
3685        .ok_or_else(|| NodeError::Reconciliation("recovered successor qlog is empty".into()))?;
3686    recorder
3687        .recover_successor_activation_from_checkpoint(
3688            stop.entry.index,
3689            stop.entry.hash,
3690            tip.index(),
3691            tip.hash(),
3692        )
3693        .map_err(|error| NodeError::Reconciliation(error.to_string()))
3694}
3695
3696fn recorder_error_status(error: &rhiza_quepaxa::Error) -> StatusCode {
3697    match error {
3698        rhiza_quepaxa::Error::NoQuorum
3699        | rhiza_quepaxa::Error::CommandUnavailable
3700        | rhiza_quepaxa::Error::Io(_)
3701        | rhiza_quepaxa::Error::RecorderRootLocked(_) => StatusCode::SERVICE_UNAVAILABLE,
3702        rhiza_quepaxa::Error::Rejected(_) => StatusCode::CONFLICT,
3703        _ => StatusCode::INTERNAL_SERVER_ERROR,
3704    }
3705}
3706
3707fn fetch_log_error_status(error: &FetchLogError) -> StatusCode {
3708    match error {
3709        FetchLogError::InvalidRequest { .. } => StatusCode::BAD_REQUEST,
3710        FetchLogError::SnapshotRequired { .. } | FetchLogError::Gap { .. } => StatusCode::CONFLICT,
3711        FetchLogError::Decode { .. } | FetchLogError::Transport { .. } => {
3712            StatusCode::SERVICE_UNAVAILABLE
3713        }
3714        FetchLogError::InvalidAnchor { .. }
3715        | FetchLogError::InvalidEntry { .. }
3716        | FetchLogError::ForeignIdentity { .. } => StatusCode::INTERNAL_SERVER_ERROR,
3717    }
3718}
3719
3720fn runtime_readiness_response(runtime: &NodeRuntime) -> Option<Response> {
3721    if runtime.is_fatal() {
3722        Some(client_error_response(
3723            StatusCode::INTERNAL_SERVER_ERROR,
3724            "fatal",
3725            false,
3726            "node is fatally unavailable",
3727            None,
3728        ))
3729    } else if !runtime.is_ready() {
3730        Some(client_error_response(
3731            StatusCode::SERVICE_UNAVAILABLE,
3732            "unavailable",
3733            true,
3734            "node is not ready",
3735            None,
3736        ))
3737    } else {
3738        None
3739    }
3740}
3741
3742#[derive(Debug, serde::Deserialize, serde::Serialize)]
3743#[serde(tag = "status", content = "body")]
3744enum FetchLogHttpResponse {
3745    Fetched(FetchLogResponse),
3746    Failed(FetchLogError),
3747}
3748
3749pub fn catch_up_missing_entries<P: LogPeer + ?Sized>(
3750    local_last_index: LogIndex,
3751    local_last_hash: LogHash,
3752    cluster_id: &str,
3753    epoch: u64,
3754    config_id: u64,
3755    peer: &P,
3756    max_entries: u32,
3757) -> Result<Vec<LogEntry>, FetchLogError> {
3758    if max_entries == 0 {
3759        return Ok(Vec::new());
3760    }
3761    if max_entries > MAX_FETCH_ENTRIES {
3762        return Err(FetchLogError::InvalidRequest {
3763            message: format!("max_entries exceeds {MAX_FETCH_ENTRIES}"),
3764        });
3765    }
3766    if cluster_id.is_empty() {
3767        return Err(FetchLogError::InvalidRequest {
3768            message: "cluster_id must not be empty".into(),
3769        });
3770    }
3771    let from_index =
3772        local_last_index
3773            .checked_add(1)
3774            .ok_or_else(|| FetchLogError::InvalidRequest {
3775                message: "local qlog index is exhausted".into(),
3776            })?;
3777    let response = peer.fetch_log(FetchLogRequest {
3778        from_index,
3779        max_entries,
3780    })?;
3781    if response.entries.len() > max_entries as usize {
3782        return Err(FetchLogError::InvalidRequest {
3783            message: "peer returned more entries than requested".into(),
3784        });
3785    }
3786    if response.last_index < local_last_index {
3787        return Err(FetchLogError::Gap {
3788            expected: local_last_index,
3789            actual: Some(response.last_index),
3790        });
3791    }
3792    if response.entries.is_empty() && response.last_index >= from_index {
3793        return Err(FetchLogError::Gap {
3794            expected: from_index,
3795            actual: None,
3796        });
3797    }
3798    validate_fetched_entries(
3799        from_index,
3800        local_last_hash,
3801        cluster_id,
3802        epoch,
3803        config_id,
3804        response.entries,
3805    )
3806}
3807
3808fn validate_fetched_entries(
3809    from_index: LogIndex,
3810    local_last_hash: LogHash,
3811    cluster_id: &str,
3812    epoch: u64,
3813    config_id: u64,
3814    entries: Vec<LogEntry>,
3815) -> Result<Vec<LogEntry>, FetchLogError> {
3816    validate_fetched_entries_with_configuration(
3817        from_index,
3818        local_last_hash,
3819        cluster_id,
3820        epoch,
3821        ConfigurationState::active(config_id, LogHash::ZERO),
3822        entries,
3823    )
3824}
3825
3826fn validate_fetched_entries_with_configuration(
3827    from_index: LogIndex,
3828    local_last_hash: LogHash,
3829    cluster_id: &str,
3830    epoch: u64,
3831    mut configuration_state: ConfigurationState,
3832    entries: Vec<LogEntry>,
3833) -> Result<Vec<LogEntry>, FetchLogError> {
3834    let mut expected = from_index;
3835    let mut previous_hash = local_last_hash;
3836    for entry in &entries {
3837        if entry.index != expected {
3838            return Err(FetchLogError::Gap {
3839                expected,
3840                actual: Some(entry.index),
3841            });
3842        }
3843        if entry.cluster_id != cluster_id || entry.epoch != epoch {
3844            return Err(FetchLogError::ForeignIdentity { index: entry.index });
3845        }
3846        if entry.prev_hash != previous_hash {
3847            return Err(FetchLogError::InvalidAnchor {
3848                expected: previous_hash,
3849                actual: entry.prev_hash,
3850            });
3851        }
3852        if entry.recompute_hash() != entry.hash {
3853            return Err(FetchLogError::InvalidEntry {
3854                index: entry.index,
3855                message: "hash does not match entry contents".into(),
3856            });
3857        }
3858        validate_entry_shape(entry).map_err(|message| FetchLogError::InvalidEntry {
3859            index: entry.index,
3860            message,
3861        })?;
3862        configuration_state = configuration_state.validate_entry(entry).map_err(|error| {
3863            FetchLogError::InvalidEntry {
3864                index: entry.index,
3865                message: error.to_string(),
3866            }
3867        })?;
3868        expected = expected
3869            .checked_add(1)
3870            .ok_or_else(|| FetchLogError::InvalidEntry {
3871                index: entry.index,
3872                message: "qlog index is exhausted".into(),
3873            })?;
3874        previous_hash = entry.hash;
3875    }
3876    Ok(entries)
3877}
3878
3879#[derive(Clone, Eq, PartialEq)]
3880pub struct PeerConfig {
3881    node_id: String,
3882    base_url: String,
3883    log_base_url: String,
3884    token: String,
3885}
3886
3887impl fmt::Debug for PeerConfig {
3888    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3889        f.debug_struct("PeerConfig")
3890            .field("node_id", &self.node_id)
3891            .field("base_url", &self.base_url)
3892            .field("log_base_url", &self.log_base_url)
3893            .field("token", &"[redacted]")
3894            .finish()
3895    }
3896}
3897
3898impl PeerConfig {
3899    pub fn new(
3900        node_id: impl Into<String>,
3901        base_url: impl Into<String>,
3902        token: impl Into<String>,
3903    ) -> Result<Self, ConfigError> {
3904        let base_url = base_url.into();
3905        Self::new_with_log_url(node_id, base_url.clone(), base_url, token)
3906    }
3907
3908    pub fn new_with_log_url(
3909        node_id: impl Into<String>,
3910        base_url: impl Into<String>,
3911        log_base_url: impl Into<String>,
3912        token: impl Into<String>,
3913    ) -> Result<Self, ConfigError> {
3914        let node_id = node_id.into();
3915        if !valid_nonblank_header_value(&node_id) {
3916            return Err(ConfigError::EmptyPeerNodeId);
3917        }
3918        let base_url = validate_peer_base_url(base_url.into())?;
3919        let log_base_url = validate_peer_base_url(log_base_url.into())?;
3920        let token = token.into();
3921        if !valid_auth_token(&token) {
3922            return Err(ConfigError::EmptyPeerToken);
3923        }
3924        Ok(Self {
3925            node_id,
3926            base_url,
3927            log_base_url,
3928            token,
3929        })
3930    }
3931
3932    pub fn node_id(&self) -> &str {
3933        &self.node_id
3934    }
3935
3936    pub fn base_url(&self) -> &str {
3937        &self.base_url
3938    }
3939
3940    pub fn log_base_url(&self) -> &str {
3941        &self.log_base_url
3942    }
3943
3944    pub fn token(&self) -> &str {
3945        &self.token
3946    }
3947}
3948
3949fn validate_peer_base_url(url: String) -> Result<String, ConfigError> {
3950    let url = url.trim_end_matches('/').to_string();
3951    if url.trim().is_empty() {
3952        return Err(ConfigError::EmptyPeerBaseUrl);
3953    }
3954    let parsed =
3955        reqwest::Url::parse(&url).map_err(|_| ConfigError::InvalidPeerBaseUrl(url.clone()))?;
3956    if !matches!(parsed.scheme(), "http" | "https")
3957        || parsed.host_str().is_none()
3958        || !parsed.username().is_empty()
3959        || parsed.password().is_some()
3960        || parsed.path() != "/"
3961        || parsed.query().is_some()
3962        || parsed.fragment().is_some()
3963    {
3964        return Err(ConfigError::InvalidPeerBaseUrl(url));
3965    }
3966    Ok(url)
3967}
3968
3969pub(crate) fn valid_nonblank_header_value(value: &str) -> bool {
3970    !value.trim().is_empty()
3971        && axum::http::HeaderValue::try_from(value).is_ok_and(|value| value.to_str().is_ok())
3972}
3973
3974pub(crate) fn valid_auth_token(value: &str) -> bool {
3975    valid_nonblank_header_value(value) && !value.chars().any(char::is_whitespace)
3976}
3977
3978#[derive(Clone, Eq, PartialEq)]
3979pub struct NodeConfig {
3980    cluster_id_source: String,
3981    logical_cluster_id: String,
3982    cluster_id: String,
3983    node_id: String,
3984    data_dir: PathBuf,
3985    epoch: u64,
3986    membership: Membership,
3987    log_initial_configuration: ConfigurationState,
3988    configuration_state: ConfigurationState,
3989    predecessor_stop_entry: Option<LogEntry>,
3990    recovery_generation: u64,
3991    peers: Vec<PeerConfig>,
3992    client_token: String,
3993    read_consistency: ReadConsistency,
3994    ack_mode: AckMode,
3995    writer_batch_max: usize,
3996    writer_batch_window: Duration,
3997    execution_profile: ExecutionProfile,
3998    #[cfg(feature = "sql")]
3999    sql_write_profiler: Option<SqlWriteProfiler>,
4000    #[cfg(feature = "sql")]
4001    sql_group_commit_queue_capacity: usize,
4002}
4003
4004impl fmt::Debug for NodeConfig {
4005    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4006        let mut debug = f.debug_struct("NodeConfig");
4007        debug
4008            .field("cluster_id_source", &self.cluster_id_source)
4009            .field("logical_cluster_id", &self.logical_cluster_id)
4010            .field("cluster_id", &self.cluster_id)
4011            .field("node_id", &self.node_id)
4012            .field("data_dir", &self.data_dir)
4013            .field("epoch", &self.epoch)
4014            .field("membership", &self.membership.members())
4015            .field("log_initial_configuration", &self.log_initial_configuration)
4016            .field("configuration_state", &self.configuration_state)
4017            .field(
4018                "predecessor_stop_entry",
4019                &self
4020                    .predecessor_stop_entry
4021                    .as_ref()
4022                    .map(|entry| (entry.index, entry.hash)),
4023            )
4024            .field("recovery_generation", &self.recovery_generation)
4025            .field("peers", &self.peers)
4026            .field("client_token", &"[redacted]")
4027            .field("read_consistency", &self.read_consistency)
4028            .field("ack_mode", &self.ack_mode)
4029            .field("writer_batch_max", &self.writer_batch_max)
4030            .field("writer_batch_window", &self.writer_batch_window)
4031            .field("execution_profile", &self.execution_profile);
4032        #[cfg(feature = "sql")]
4033        debug.field(
4034            "sql_write_profiler",
4035            &self.sql_write_profiler.as_ref().map(|_| "installed"),
4036        );
4037        #[cfg(feature = "sql")]
4038        debug.field(
4039            "sql_group_commit_queue_capacity",
4040            &self.sql_group_commit_queue_capacity,
4041        );
4042        debug.finish()
4043    }
4044}
4045
4046impl NodeConfig {
4047    pub fn new<P>(
4048        cluster_id: impl Into<String>,
4049        node_id: impl Into<String>,
4050        data_dir: PathBuf,
4051        epoch: u64,
4052        config_id: u64,
4053        peers: P,
4054        client_token: impl Into<String>,
4055    ) -> Result<Self, ConfigError>
4056    where
4057        P: Into<Vec<PeerConfig>>,
4058    {
4059        let peers = peers.into();
4060        let membership = membership_from_peers(&peers)?;
4061        let configuration_state = ConfigurationState::active(config_id, membership.digest());
4062        Self::new_with_configuration(
4063            cluster_id,
4064            node_id,
4065            data_dir,
4066            epoch,
4067            membership,
4068            configuration_state,
4069            peers,
4070            client_token,
4071        )
4072    }
4073
4074    pub fn new_embedded<I, S>(
4075        cluster_id: impl Into<String>,
4076        node_id: impl Into<String>,
4077        data_dir: PathBuf,
4078        epoch: u64,
4079        config_id: u64,
4080        members: I,
4081    ) -> Result<Self, ConfigError>
4082    where
4083        I: IntoIterator<Item = S>,
4084        S: Into<String>,
4085    {
4086        let cluster_id = cluster_id.into();
4087        let node_id = node_id.into();
4088        validate_node_identity(&cluster_id, &node_id, &data_dir, epoch, config_id)?;
4089        let membership = membership_from_node_ids(members.into_iter().map(Into::into).collect())?;
4090        if !membership.contains(&node_id) {
4091            return Err(ConfigError::LocalNodeMissing);
4092        }
4093        let configuration_state = ConfigurationState::active(config_id, membership.digest());
4094        Self::from_validated_parts(
4095            cluster_id,
4096            node_id,
4097            data_dir,
4098            epoch,
4099            membership,
4100            configuration_state,
4101            Vec::new(),
4102            String::new(),
4103        )
4104    }
4105
4106    #[allow(clippy::too_many_arguments)]
4107    pub fn new_with_configuration<P>(
4108        cluster_id: impl Into<String>,
4109        node_id: impl Into<String>,
4110        data_dir: PathBuf,
4111        epoch: u64,
4112        membership: Membership,
4113        configuration_state: ConfigurationState,
4114        peers: P,
4115        client_token: impl Into<String>,
4116    ) -> Result<Self, ConfigError>
4117    where
4118        P: Into<Vec<PeerConfig>>,
4119    {
4120        let cluster_id = cluster_id.into();
4121        let node_id = node_id.into();
4122        let client_token = client_token.into();
4123        let peers = peers.into();
4124
4125        validate_node_identity(
4126            &cluster_id,
4127            &node_id,
4128            &data_dir,
4129            epoch,
4130            configuration_state.config_id(),
4131        )?;
4132        if !(3..=7).contains(&peers.len()) {
4133            return Err(ConfigError::InvalidPeerCount(peers.len()));
4134        }
4135        let mut peer_ids = HashSet::with_capacity(peers.len());
4136        let mut peer_tokens = HashSet::with_capacity(peers.len());
4137        for peer in &peers {
4138            if !peer_ids.insert(peer.node_id.clone()) {
4139                return Err(ConfigError::DuplicatePeerNodeId(peer.node_id.clone()));
4140            }
4141            if !peer_tokens.insert(peer.token.as_str()) {
4142                return Err(ConfigError::DuplicatePeerToken);
4143            }
4144        }
4145        if !peer_ids.contains(&node_id) {
4146            return Err(ConfigError::LocalNodeMissing);
4147        }
4148        if peer_ids.len() != membership.members().len()
4149            || membership
4150                .members()
4151                .iter()
4152                .any(|member| !peer_ids.contains(member))
4153        {
4154            return Err(ConfigError::PeerMembershipMismatch);
4155        }
4156        if configuration_state.is_active() && configuration_state.digest() != membership.digest() {
4157            return Err(ConfigError::PeerMembershipMismatch);
4158        }
4159        if !valid_auth_token(&client_token) {
4160            return Err(ConfigError::EmptyClientToken);
4161        }
4162        if peer_tokens.contains(client_token.as_str()) {
4163            return Err(ConfigError::ClientTokenConflictsWithPeer);
4164        }
4165
4166        Self::from_validated_parts(
4167            cluster_id,
4168            node_id,
4169            data_dir,
4170            epoch,
4171            membership,
4172            configuration_state,
4173            peers,
4174            client_token,
4175        )
4176    }
4177
4178    #[allow(clippy::too_many_arguments)]
4179    fn from_validated_parts(
4180        cluster_id: String,
4181        node_id: String,
4182        data_dir: PathBuf,
4183        epoch: u64,
4184        membership: Membership,
4185        configuration_state: ConfigurationState,
4186        peers: Vec<PeerConfig>,
4187        client_token: String,
4188    ) -> Result<Self, ConfigError> {
4189        let log_initial_configuration = ConfigurationState::active(
4190            configuration_state.config_id(),
4191            configuration_state.digest(),
4192        );
4193        let execution_profile =
4194            canonical_cluster_profile(&cluster_id).unwrap_or(ExecutionProfile::Sqlite);
4195        let logical_cluster_id = ["rhiza:sql:", "rhiza:graph:", "rhiza:kv:"]
4196            .into_iter()
4197            .find_map(|prefix| cluster_id.strip_prefix(prefix))
4198            .unwrap_or(&cluster_id)
4199            .to_owned();
4200        let effective_cluster_id = effective_cluster_id(execution_profile, &cluster_id)?;
4201        Ok(Self {
4202            cluster_id_source: cluster_id,
4203            cluster_id: effective_cluster_id,
4204            logical_cluster_id,
4205            node_id,
4206            data_dir,
4207            epoch,
4208            membership,
4209            log_initial_configuration,
4210            configuration_state,
4211            predecessor_stop_entry: None,
4212            recovery_generation: 1,
4213            peers,
4214            client_token,
4215            read_consistency: ReadConsistency::ReadBarrier,
4216            ack_mode: AckMode::HaFirst,
4217            writer_batch_max: DEFAULT_WRITER_BATCH_MAX,
4218            writer_batch_window: DEFAULT_WRITER_BATCH_WINDOW,
4219            execution_profile,
4220            #[cfg(feature = "sql")]
4221            sql_write_profiler: None,
4222            #[cfg(feature = "sql")]
4223            sql_group_commit_queue_capacity: DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY,
4224        })
4225    }
4226
4227    pub fn with_execution_profile(
4228        mut self,
4229        execution_profile: ExecutionProfile,
4230    ) -> Result<Self, ConfigError> {
4231        self.cluster_id = effective_cluster_id(execution_profile, &self.cluster_id_source)?;
4232        self.execution_profile = execution_profile;
4233        Ok(self)
4234    }
4235
4236    pub fn with_read_consistency(mut self, read_consistency: ReadConsistency) -> Self {
4237        self.read_consistency = read_consistency;
4238        self
4239    }
4240
4241    #[cfg(feature = "sql")]
4242    pub fn with_sql_write_profiler(mut self, profiler: SqlWriteProfiler) -> Self {
4243        self.sql_write_profiler = Some(profiler);
4244        self
4245    }
4246
4247    #[cfg(feature = "sql")]
4248    pub fn with_sql_group_commit_queue_capacity(
4249        mut self,
4250        capacity: usize,
4251    ) -> Result<Self, ConfigError> {
4252        if !(1..=MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY).contains(&capacity) {
4253            return Err(ConfigError::InvalidSqlGroupCommitQueueCapacity(capacity));
4254        }
4255        self.sql_group_commit_queue_capacity = capacity;
4256        Ok(self)
4257    }
4258
4259    pub fn with_ack_mode(mut self, ack_mode: AckMode) -> Self {
4260        self.ack_mode = ack_mode;
4261        self
4262    }
4263
4264    pub fn with_writer_batching(
4265        mut self,
4266        max: usize,
4267        window: Duration,
4268    ) -> Result<Self, ConfigError> {
4269        if max == 0 || max > MAX_WRITE_BATCH_MEMBERS {
4270            return Err(ConfigError::InvalidWriterBatchMax(max));
4271        }
4272        if window.is_zero() || window >= CLIENT_WRITE_WAIT_TIMEOUT {
4273            return Err(ConfigError::InvalidWriterBatchWindow);
4274        }
4275        self.writer_batch_max = max;
4276        self.writer_batch_window = window;
4277        Ok(self)
4278    }
4279
4280    pub fn with_log_initial_configuration(mut self, configuration: ConfigurationState) -> Self {
4281        self.log_initial_configuration = configuration;
4282        self
4283    }
4284
4285    pub fn with_predecessor_stop_entry(mut self, entry: LogEntry) -> Self {
4286        self.predecessor_stop_entry = Some(entry);
4287        self
4288    }
4289
4290    /// Binds an unactivated successor draft to the exact predecessor Stop it will adopt.
4291    pub fn bind_predecessor_stop(
4292        mut self,
4293        predecessor_membership: &Membership,
4294        entry: LogEntry,
4295    ) -> Result<Self, ConfigError> {
4296        let target_config_id = self.config_id();
4297        if entry.cluster_id != self.cluster_id
4298            || entry.epoch != self.epoch
4299            || entry.config_id.checked_add(1) != Some(target_config_id)
4300            || entry.recompute_hash() != entry.hash
4301        {
4302            return Err(ConfigError::InvalidPredecessorTransition(
4303                "Stop identity does not match the successor draft".into(),
4304            ));
4305        }
4306        let ConfigChange::BoundStop { successor } =
4307            ConfigChange::recognize_parts(entry.entry_type, &entry.payload).map_err(|_| {
4308                ConfigError::InvalidPredecessorTransition(
4309                    "predecessor entry is not a bound Stop".into(),
4310                )
4311            })?
4312        else {
4313            return Err(ConfigError::InvalidPredecessorTransition(
4314                "predecessor entry is not a bound Stop".into(),
4315            ));
4316        };
4317        if successor.cluster_id() != self.cluster_id
4318            || successor.predecessor_config_id() != entry.config_id
4319            || successor.predecessor_config_digest() != predecessor_membership.digest()
4320            || successor.config_id() != target_config_id
4321            || successor.digest() != self.membership.digest()
4322            || successor.members() != self.membership.members()
4323        {
4324            return Err(ConfigError::InvalidPredecessorTransition(
4325                "Stop successor descriptor does not match the successor draft".into(),
4326            ));
4327        }
4328        let predecessor =
4329            ConfigurationState::active(entry.config_id, predecessor_membership.digest());
4330        let stopped = predecessor
4331            .validate_entry(&entry)
4332            .map_err(|error| ConfigError::InvalidPredecessorTransition(error.to_string()))?;
4333        self.log_initial_configuration = predecessor;
4334        self.configuration_state = stopped;
4335        self.predecessor_stop_entry = Some(entry);
4336        Ok(self)
4337    }
4338
4339    pub fn with_recovery_generation(
4340        mut self,
4341        recovery_generation: u64,
4342    ) -> Result<Self, ConfigError> {
4343        validate_recovery_generation(recovery_generation)?;
4344        self.recovery_generation = recovery_generation;
4345        Ok(self)
4346    }
4347
4348    pub fn cluster_id(&self) -> &str {
4349        &self.cluster_id
4350    }
4351
4352    pub fn logical_cluster_id(&self) -> &str {
4353        &self.logical_cluster_id
4354    }
4355
4356    pub fn node_id(&self) -> &str {
4357        &self.node_id
4358    }
4359
4360    pub fn data_dir(&self) -> &PathBuf {
4361        &self.data_dir
4362    }
4363
4364    pub const fn epoch(&self) -> u64 {
4365        self.epoch
4366    }
4367
4368    pub const fn config_id(&self) -> u64 {
4369        self.configuration_state.config_id()
4370    }
4371
4372    pub const fn recovery_generation(&self) -> u64 {
4373        self.recovery_generation
4374    }
4375
4376    pub fn peers(&self) -> &[PeerConfig] {
4377        &self.peers
4378    }
4379
4380    pub const fn membership(&self) -> &Membership {
4381        &self.membership
4382    }
4383
4384    pub const fn configuration_state(&self) -> &ConfigurationState {
4385        &self.configuration_state
4386    }
4387
4388    pub const fn log_initial_configuration(&self) -> &ConfigurationState {
4389        &self.log_initial_configuration
4390    }
4391
4392    pub fn client_token(&self) -> &str {
4393        &self.client_token
4394    }
4395
4396    pub const fn read_consistency(&self) -> ReadConsistency {
4397        self.read_consistency
4398    }
4399
4400    pub const fn ack_mode(&self) -> AckMode {
4401        self.ack_mode
4402    }
4403
4404    pub const fn writer_batch_max(&self) -> usize {
4405        self.writer_batch_max
4406    }
4407
4408    pub const fn writer_batch_window(&self) -> Duration {
4409        self.writer_batch_window
4410    }
4411
4412    pub const fn execution_profile(&self) -> ExecutionProfile {
4413        self.execution_profile
4414    }
4415
4416    #[cfg(feature = "sql")]
4417    pub const fn sql_write_profiler(&self) -> Option<&SqlWriteProfiler> {
4418        self.sql_write_profiler.as_ref()
4419    }
4420
4421    #[cfg(feature = "sql")]
4422    pub const fn sql_group_commit_queue_capacity(&self) -> usize {
4423        self.sql_group_commit_queue_capacity
4424    }
4425}
4426
4427fn validate_node_identity(
4428    cluster_id: &str,
4429    node_id: &str,
4430    data_dir: &Path,
4431    epoch: u64,
4432    config_id: u64,
4433) -> Result<(), ConfigError> {
4434    if cluster_id.trim().is_empty() {
4435        return Err(ConfigError::EmptyClusterId);
4436    }
4437    if node_id.trim().is_empty() {
4438        return Err(ConfigError::EmptyNodeId);
4439    }
4440    if data_dir.as_os_str().is_empty() {
4441        return Err(ConfigError::EmptyDataDir);
4442    }
4443    if epoch == 0 {
4444        return Err(ConfigError::InvalidEpoch);
4445    }
4446    if config_id == 0 {
4447        return Err(ConfigError::InvalidConfigId);
4448    }
4449    Ok(())
4450}
4451
4452fn membership_from_node_ids(members: Vec<String>) -> Result<Membership, ConfigError> {
4453    if !(3..=7).contains(&members.len()) {
4454        return Err(ConfigError::InvalidPeerCount(members.len()));
4455    }
4456    Membership::from_voters(members.clone()).map_err(|error| match error {
4457        rhiza_quepaxa::Error::DuplicateRecorderIdentity => {
4458            let duplicate = members
4459                .iter()
4460                .find(|candidate| {
4461                    members
4462                        .iter()
4463                        .filter(|member| *member == *candidate)
4464                        .count()
4465                        > 1
4466                })
4467                .cloned()
4468                .unwrap_or_default();
4469            ConfigError::DuplicatePeerNodeId(duplicate)
4470        }
4471        rhiza_quepaxa::Error::EmptyRecorderIdentity => ConfigError::EmptyPeerNodeId,
4472        _ => ConfigError::InvalidPeerCount(members.len()),
4473    })
4474}
4475
4476fn membership_from_peers(peers: &[PeerConfig]) -> Result<Membership, ConfigError> {
4477    membership_from_node_ids(peers.iter().map(|peer| peer.node_id.clone()).collect())
4478}
4479
4480#[derive(Clone, Debug, Eq, PartialEq)]
4481pub enum NodeError {
4482    UnsupportedAckMode(AckMode),
4483    ExecutionProfileMismatch {
4484        expected: ExecutionProfile,
4485        actual: ExecutionProfile,
4486    },
4487    DataRootLocked(PathBuf),
4488    SnapshotRequired(Box<RecoveryAnchor>),
4489    Storage(String),
4490    Reconciliation(String),
4491    Invariant(String),
4492    Unavailable(String),
4493    ResourceExhausted(String),
4494    ConfigurationTransition {
4495        state: Box<ConfigurationState>,
4496    },
4497    Contention(String),
4498    WinnerLimitExceeded,
4499    #[cfg(feature = "sql")]
4500    RequestConflict(RequestConflict),
4501    InvalidRequest(String),
4502    #[cfg(feature = "sql")]
4503    InvalidSqlStatement {
4504        statement_index: usize,
4505        message: String,
4506    },
4507    PreconditionFailed(String),
4508    Fatal(String),
4509}
4510
4511impl fmt::Display for NodeError {
4512    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4513        match self {
4514            Self::UnsupportedAckMode(mode) => {
4515                write!(
4516                    f,
4517                    "ack mode {mode:?} is unsupported without synchronous archive"
4518                )
4519            }
4520            Self::ExecutionProfileMismatch { expected, actual } => write!(
4521                f,
4522                "execution profile mismatch: expected {expected}, got {actual}"
4523            ),
4524            Self::DataRootLocked(path) => {
4525                write!(f, "node data root is already owned: {}", path.display())
4526            }
4527            Self::SnapshotRequired(anchor) => write!(
4528                f,
4529                "snapshot restore required at qlog anchor {}",
4530                anchor.compacted().index()
4531            ),
4532            Self::Storage(message) => write!(f, "node storage failed: {message}"),
4533            Self::Reconciliation(message) => write!(f, "node reconciliation failed: {message}"),
4534            Self::Invariant(message) => write!(f, "node invariant failed: {message}"),
4535            Self::Unavailable(message) => write!(f, "node unavailable: {message}"),
4536            Self::ResourceExhausted(message) => {
4537                write!(f, "node query resources exhausted: {message}")
4538            }
4539            Self::ConfigurationTransition { state } => write!(
4540                f,
4541                "node unavailable during configuration transition: {state:?}"
4542            ),
4543            Self::Contention(message) => write!(f, "node contention: {message}"),
4544            Self::WinnerLimitExceeded => write!(f, "foreign winner retry limit exceeded"),
4545            #[cfg(feature = "sql")]
4546            Self::RequestConflict(conflict) => conflict.fmt(f),
4547            Self::InvalidRequest(message) => write!(f, "invalid request: {message}"),
4548            #[cfg(feature = "sql")]
4549            Self::InvalidSqlStatement {
4550                statement_index,
4551                message,
4552            } => write!(
4553                f,
4554                "invalid SQL statement at index {statement_index}: {message}"
4555            ),
4556            Self::PreconditionFailed(message) => write!(f, "precondition failed: {message}"),
4557            Self::Fatal(message) => write!(f, "node is fatally unavailable: {message}"),
4558        }
4559    }
4560}
4561
4562impl std::error::Error for NodeError {}
4563
4564impl NodeError {
4565    pub fn classification(&self) -> ErrorClassification {
4566        let (code, retryable) = match self {
4567            Self::InvalidRequest(_) => ("invalid_request", false),
4568            #[cfg(feature = "sql")]
4569            Self::InvalidSqlStatement { .. } => ("invalid_request", false),
4570            #[cfg(feature = "sql")]
4571            Self::RequestConflict(_) => ("request_conflict", false),
4572            Self::PreconditionFailed(_) => ("precondition_failed", false),
4573            Self::SnapshotRequired(_) => ("snapshot_required", false),
4574            Self::Unavailable(_) => ("unavailable", true),
4575            Self::ResourceExhausted(_) => ("resource_exhausted", true),
4576            Self::ConfigurationTransition { .. } => ("configuration_transition", true),
4577            Self::Contention(_) => ("contention", true),
4578            Self::WinnerLimitExceeded => ("winner_limit_exceeded", true),
4579            Self::DataRootLocked(_) => ("data_root_locked", false),
4580            Self::UnsupportedAckMode(_) => ("unsupported_ack_mode", false),
4581            Self::ExecutionProfileMismatch { .. } => ("execution_profile_mismatch", false),
4582            Self::Storage(_) => ("storage_error", false),
4583            Self::Reconciliation(_) => ("reconciliation_error", false),
4584            Self::Invariant(_) => ("invariant_violation", false),
4585            Self::Fatal(_) => ("fatal", false),
4586        };
4587        ErrorClassification::from_server_code(code, retryable)
4588    }
4589}
4590
4591pub type RuntimeError = NodeError;
4592
4593#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4594#[serde(rename_all = "snake_case")]
4595pub enum RuntimeConfigurationStatus {
4596    Active,
4597    Stopped,
4598    AwaitingActivation,
4599}
4600
4601#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4602pub struct NodeStatus {
4603    pub ready: bool,
4604    pub configuration_status: RuntimeConfigurationStatus,
4605    pub configuration_state: ConfigurationState,
4606    pub stop_anchor: Option<rhiza_core::LogAnchor>,
4607    pub active_config_id: u64,
4608    pub active_membership_digest: LogHash,
4609}
4610
4611#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4612#[serde(deny_unknown_fields)]
4613pub struct StopInformation {
4614    pub entry: LogEntry,
4615    pub proof: DecisionProof,
4616}
4617
4618#[cfg(feature = "sql")]
4619#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4620#[serde(deny_unknown_fields)]
4621pub struct WriteRequest {
4622    pub request_id: String,
4623    pub key: String,
4624    pub value: String,
4625}
4626
4627#[cfg(feature = "sql")]
4628#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4629pub struct WriteResponse {
4630    pub applied_index: LogIndex,
4631    pub hash: LogHash,
4632}
4633
4634#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4635#[serde(deny_unknown_fields)]
4636pub struct ClientErrorResponse {
4637    pub code: String,
4638    pub retryable: bool,
4639    pub message: String,
4640    #[serde(skip_serializing_if = "Option::is_none")]
4641    pub statement_index: Option<usize>,
4642}
4643
4644#[cfg(feature = "sql")]
4645#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4646#[serde(deny_unknown_fields)]
4647pub struct ReadRequest {
4648    pub key: String,
4649    pub consistency: Option<ReadConsistency>,
4650}
4651
4652#[cfg(feature = "sql")]
4653#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4654pub struct ReadResponse {
4655    pub value: Option<String>,
4656    pub applied_index: LogIndex,
4657    pub hash: LogHash,
4658}
4659
4660#[cfg(feature = "sql")]
4661#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4662#[serde(deny_unknown_fields)]
4663pub struct SqlExecuteRequest {
4664    pub request_id: String,
4665    pub statements: Vec<SqlStatement>,
4666}
4667
4668#[cfg(feature = "sql")]
4669#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4670#[serde(deny_unknown_fields)]
4671pub struct SqlExecuteResponse {
4672    pub applied_index: LogIndex,
4673    pub hash: LogHash,
4674    pub results: Vec<SqlStatementResult>,
4675}
4676
4677#[cfg(feature = "sql")]
4678impl From<WriteResponse> for SqlExecuteResponse {
4679    fn from(response: WriteResponse) -> Self {
4680        sql_execute_response(response, None)
4681    }
4682}
4683
4684#[cfg(feature = "sql")]
4685fn sql_execute_response(
4686    response: WriteResponse,
4687    result: Option<SqlCommandResult>,
4688) -> SqlExecuteResponse {
4689    let results = result
4690        .map(|result| {
4691            result
4692                .statement_results
4693                .into_iter()
4694                .enumerate()
4695                .map(|(statement_index, result)| SqlStatementResult {
4696                    statement_index,
4697                    rows_affected: result.rows_affected,
4698                    returning: result.returning,
4699                })
4700                .collect()
4701        })
4702        .unwrap_or_default();
4703    SqlExecuteResponse {
4704        applied_index: response.applied_index,
4705        hash: response.hash,
4706        results,
4707    }
4708}
4709
4710#[cfg(feature = "sql")]
4711#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4712pub struct SqlStatementResult {
4713    pub statement_index: usize,
4714    pub rows_affected: u64,
4715    #[serde(skip_serializing_if = "Option::is_none")]
4716    pub returning: Option<SqlQueryResult>,
4717}
4718
4719#[cfg(feature = "sql")]
4720#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4721#[serde(deny_unknown_fields)]
4722pub struct SqlQueryRequest {
4723    pub statement: SqlStatement,
4724    pub consistency: Option<ReadConsistency>,
4725    pub max_rows: Option<u32>,
4726}
4727
4728#[cfg(feature = "sql")]
4729#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4730pub struct SqlQueryResponse {
4731    pub columns: Vec<String>,
4732    pub rows: Vec<Vec<SqlValue>>,
4733    pub applied_index: LogIndex,
4734    pub hash: LogHash,
4735}
4736
4737#[cfg(feature = "graph")]
4738#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4739#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4740pub enum GraphValueDto {
4741    Null,
4742    Bool(bool),
4743    I64(i64),
4744    U64(u64),
4745    F64(f64),
4746    String(String),
4747    Bytes(String),
4748}
4749
4750#[cfg(feature = "graph")]
4751#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4752#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4753pub enum GraphQueryParameterDto {
4754    Null,
4755    Bool(bool),
4756    I64(i64),
4757    U64(u64),
4758    F64(f64),
4759    String(String),
4760    Bytes(String),
4761    List(Vec<Self>),
4762    Struct(BTreeMap<String, Self>),
4763}
4764
4765#[cfg(feature = "graph")]
4766impl TryFrom<GraphQueryParameterDto> for GraphParameterValue {
4767    type Error = NodeError;
4768
4769    fn try_from(value: GraphQueryParameterDto) -> Result<Self, Self::Error> {
4770        Ok(match value {
4771            GraphQueryParameterDto::Null => Self::Null,
4772            GraphQueryParameterDto::Bool(value) => Self::Bool(value),
4773            GraphQueryParameterDto::I64(value) => Self::I64(value),
4774            GraphQueryParameterDto::U64(value) => Self::U64(value),
4775            GraphQueryParameterDto::F64(value) => Self::F64(
4776                CanonicalF64::new(value)
4777                    .map_err(|error| NodeError::InvalidRequest(error.to_string()))?,
4778            ),
4779            GraphQueryParameterDto::String(value) => Self::String(value),
4780            GraphQueryParameterDto::Bytes(value) => {
4781                Self::Bytes(decode_base64("graph parameter bytes", &value)?)
4782            }
4783            GraphQueryParameterDto::List(values) => Self::List(
4784                values
4785                    .into_iter()
4786                    .map(Self::try_from)
4787                    .collect::<Result<_, _>>()?,
4788            ),
4789            GraphQueryParameterDto::Struct(values) => Self::Struct(
4790                values
4791                    .into_iter()
4792                    .map(|(name, value)| Self::try_from(value).map(|value| (name, value)))
4793                    .collect::<Result<_, _>>()?,
4794            ),
4795        })
4796    }
4797}
4798
4799#[cfg(feature = "graph")]
4800#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4801#[serde(deny_unknown_fields)]
4802pub struct GraphQueryStatementDto {
4803    pub cypher: String,
4804    #[serde(default)]
4805    pub parameters: BTreeMap<String, GraphQueryParameterDto>,
4806}
4807
4808#[cfg(feature = "graph")]
4809#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4810#[serde(deny_unknown_fields)]
4811pub struct GraphQueryRequest {
4812    pub statement: GraphQueryStatementDto,
4813    pub consistency: Option<ReadConsistency>,
4814    pub max_rows: Option<u32>,
4815}
4816
4817#[cfg(feature = "graph")]
4818#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4819pub struct GraphInternalIdDto {
4820    pub offset: u64,
4821    pub table_id: u64,
4822}
4823
4824#[cfg(feature = "graph")]
4825impl From<GraphInternalId> for GraphInternalIdDto {
4826    fn from(value: GraphInternalId) -> Self {
4827        Self {
4828            offset: value.offset,
4829            table_id: value.table_id,
4830        }
4831    }
4832}
4833
4834#[cfg(feature = "graph")]
4835#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4836pub struct GraphNamedValueDto {
4837    pub name: String,
4838    pub value: GraphResultValueDto,
4839}
4840
4841#[cfg(feature = "graph")]
4842#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4843pub struct GraphNodeDto {
4844    pub id: GraphInternalIdDto,
4845    pub label: String,
4846    pub properties: Vec<GraphNamedValueDto>,
4847}
4848
4849#[cfg(feature = "graph")]
4850impl From<GraphNode> for GraphNodeDto {
4851    fn from(value: GraphNode) -> Self {
4852        Self {
4853            id: value.id.into(),
4854            label: value.label,
4855            properties: named_graph_values(value.properties),
4856        }
4857    }
4858}
4859
4860#[cfg(feature = "graph")]
4861#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4862pub struct GraphRelDto {
4863    pub src: GraphInternalIdDto,
4864    pub dst: GraphInternalIdDto,
4865    pub label: String,
4866    pub properties: Vec<GraphNamedValueDto>,
4867}
4868
4869#[cfg(feature = "graph")]
4870impl From<GraphRel> for GraphRelDto {
4871    fn from(value: GraphRel) -> Self {
4872        Self {
4873            src: value.src.into(),
4874            dst: value.dst.into(),
4875            label: value.label,
4876            properties: named_graph_values(value.properties),
4877        }
4878    }
4879}
4880
4881#[cfg(feature = "graph")]
4882#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4883pub struct GraphRecursiveRelDto {
4884    pub nodes: Vec<GraphNodeDto>,
4885    pub rels: Vec<GraphRelDto>,
4886}
4887
4888#[cfg(feature = "graph")]
4889#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4890pub struct GraphMapEntryDto {
4891    pub key: GraphResultValueDto,
4892    pub value: GraphResultValueDto,
4893}
4894
4895#[cfg(feature = "graph")]
4896#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4897pub struct GraphNamedLogicalTypeDto {
4898    pub name: String,
4899    pub logical_type: GraphLogicalTypeDto,
4900}
4901
4902#[cfg(feature = "graph")]
4903#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4904pub struct GraphArrayTypeDto {
4905    pub element_type: Box<GraphLogicalTypeDto>,
4906    pub length: u64,
4907}
4908
4909#[cfg(feature = "graph")]
4910#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4911pub struct GraphMapTypeDto {
4912    pub key_type: Box<GraphLogicalTypeDto>,
4913    pub value_type: Box<GraphLogicalTypeDto>,
4914}
4915
4916#[cfg(feature = "graph")]
4917#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4918pub struct GraphDecimalTypeDto {
4919    pub precision: u32,
4920    pub scale: u32,
4921}
4922
4923#[cfg(feature = "graph")]
4924#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4925#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4926pub enum GraphLogicalTypeDto {
4927    Any,
4928    Bool,
4929    Serial,
4930    I64,
4931    I32,
4932    I16,
4933    I8,
4934    U64,
4935    U32,
4936    U16,
4937    U8,
4938    I128,
4939    F64,
4940    F32,
4941    Date,
4942    Interval,
4943    Timestamp,
4944    TimestampTz,
4945    TimestampNs,
4946    TimestampMs,
4947    TimestampSec,
4948    InternalId,
4949    String,
4950    Json,
4951    Bytes,
4952    List(Box<Self>),
4953    Array(GraphArrayTypeDto),
4954    Struct(Vec<GraphNamedLogicalTypeDto>),
4955    Node,
4956    Rel,
4957    RecursiveRel,
4958    Map(GraphMapTypeDto),
4959    Union(Vec<GraphNamedLogicalTypeDto>),
4960    Uuid,
4961    Decimal(GraphDecimalTypeDto),
4962}
4963
4964#[cfg(feature = "graph")]
4965impl From<GraphLogicalType> for GraphLogicalTypeDto {
4966    fn from(value: GraphLogicalType) -> Self {
4967        match value {
4968            GraphLogicalType::Any => Self::Any,
4969            GraphLogicalType::Bool => Self::Bool,
4970            GraphLogicalType::Serial => Self::Serial,
4971            GraphLogicalType::I64 => Self::I64,
4972            GraphLogicalType::I32 => Self::I32,
4973            GraphLogicalType::I16 => Self::I16,
4974            GraphLogicalType::I8 => Self::I8,
4975            GraphLogicalType::U64 => Self::U64,
4976            GraphLogicalType::U32 => Self::U32,
4977            GraphLogicalType::U16 => Self::U16,
4978            GraphLogicalType::U8 => Self::U8,
4979            GraphLogicalType::I128 => Self::I128,
4980            GraphLogicalType::F64 => Self::F64,
4981            GraphLogicalType::F32 => Self::F32,
4982            GraphLogicalType::Date => Self::Date,
4983            GraphLogicalType::Interval => Self::Interval,
4984            GraphLogicalType::Timestamp => Self::Timestamp,
4985            GraphLogicalType::TimestampTz => Self::TimestampTz,
4986            GraphLogicalType::TimestampNs => Self::TimestampNs,
4987            GraphLogicalType::TimestampMs => Self::TimestampMs,
4988            GraphLogicalType::TimestampSec => Self::TimestampSec,
4989            GraphLogicalType::InternalId => Self::InternalId,
4990            GraphLogicalType::String => Self::String,
4991            GraphLogicalType::Json => Self::Json,
4992            GraphLogicalType::Bytes => Self::Bytes,
4993            GraphLogicalType::List(element_type) => Self::List(Box::new((*element_type).into())),
4994            GraphLogicalType::Array {
4995                element_type,
4996                length,
4997            } => Self::Array(GraphArrayTypeDto {
4998                element_type: Box::new((*element_type).into()),
4999                length,
5000            }),
5001            GraphLogicalType::Struct(fields) => Self::Struct(named_graph_logical_types(fields)),
5002            GraphLogicalType::Node => Self::Node,
5003            GraphLogicalType::Rel => Self::Rel,
5004            GraphLogicalType::RecursiveRel => Self::RecursiveRel,
5005            GraphLogicalType::Map {
5006                key_type,
5007                value_type,
5008            } => Self::Map(GraphMapTypeDto {
5009                key_type: Box::new((*key_type).into()),
5010                value_type: Box::new((*value_type).into()),
5011            }),
5012            GraphLogicalType::Union(types) => Self::Union(named_graph_logical_types(types)),
5013            GraphLogicalType::Uuid => Self::Uuid,
5014            GraphLogicalType::Decimal { precision, scale } => {
5015                Self::Decimal(GraphDecimalTypeDto { precision, scale })
5016            }
5017        }
5018    }
5019}
5020
5021#[cfg(feature = "graph")]
5022#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5023pub struct GraphCollectionValueDto {
5024    pub element_type: GraphLogicalTypeDto,
5025    pub values: Vec<GraphResultValueDto>,
5026}
5027
5028#[cfg(feature = "graph")]
5029#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5030pub struct GraphMapValueDto {
5031    pub key_type: GraphLogicalTypeDto,
5032    pub value_type: GraphLogicalTypeDto,
5033    pub entries: Vec<GraphMapEntryDto>,
5034}
5035
5036#[cfg(feature = "graph")]
5037#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5038pub struct GraphUnionValueDto {
5039    pub variants: Vec<GraphNamedLogicalTypeDto>,
5040    pub value: Box<GraphResultValueDto>,
5041}
5042
5043#[cfg(feature = "graph")]
5044#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5045#[serde(tag = "type", content = "value", rename_all = "snake_case")]
5046pub enum GraphResultValueDto {
5047    Null(GraphLogicalTypeDto),
5048    Bool(bool),
5049    I64(i64),
5050    I32(i32),
5051    I16(i16),
5052    I8(i8),
5053    U64(u64),
5054    U32(u32),
5055    U16(u16),
5056    U8(u8),
5057    I128(String),
5058    F64(f64),
5059    F32(String),
5060    Date(String),
5061    Interval(String),
5062    Timestamp(String),
5063    TimestampTz(String),
5064    TimestampNs(String),
5065    TimestampMs(String),
5066    TimestampSec(String),
5067    InternalId(GraphInternalIdDto),
5068    String(String),
5069    Json(String),
5070    Bytes(String),
5071    List(GraphCollectionValueDto),
5072    Array(GraphCollectionValueDto),
5073    Struct(Vec<GraphNamedValueDto>),
5074    Node(GraphNodeDto),
5075    Rel(GraphRelDto),
5076    RecursiveRel(GraphRecursiveRelDto),
5077    Map(GraphMapValueDto),
5078    Union(GraphUnionValueDto),
5079    Uuid(String),
5080    Decimal(String),
5081}
5082
5083#[cfg(feature = "graph")]
5084impl From<GraphResultValue> for GraphResultValueDto {
5085    fn from(value: GraphResultValue) -> Self {
5086        match value {
5087            GraphResultValue::Null(value) => Self::Null(value.into()),
5088            GraphResultValue::Bool(value) => Self::Bool(value),
5089            GraphResultValue::I64(value) => Self::I64(value),
5090            GraphResultValue::I32(value) => Self::I32(value),
5091            GraphResultValue::I16(value) => Self::I16(value),
5092            GraphResultValue::I8(value) => Self::I8(value),
5093            GraphResultValue::U64(value) => Self::U64(value),
5094            GraphResultValue::U32(value) => Self::U32(value),
5095            GraphResultValue::U16(value) => Self::U16(value),
5096            GraphResultValue::U8(value) => Self::U8(value),
5097            GraphResultValue::I128(value) => Self::I128(value),
5098            GraphResultValue::F64(value) => Self::F64(value.get()),
5099            GraphResultValue::F32(value) => Self::F32(value),
5100            GraphResultValue::Date(value) => Self::Date(value),
5101            GraphResultValue::Interval(value) => Self::Interval(value),
5102            GraphResultValue::Timestamp(value) => Self::Timestamp(value),
5103            GraphResultValue::TimestampTz(value) => Self::TimestampTz(value),
5104            GraphResultValue::TimestampNs(value) => Self::TimestampNs(value),
5105            GraphResultValue::TimestampMs(value) => Self::TimestampMs(value),
5106            GraphResultValue::TimestampSec(value) => Self::TimestampSec(value),
5107            GraphResultValue::InternalId(value) => Self::InternalId(value.into()),
5108            GraphResultValue::String(value) => Self::String(value),
5109            GraphResultValue::Json(value) => Self::Json(value),
5110            GraphResultValue::Bytes(value) => Self::Bytes(encode_base64(&value)),
5111            GraphResultValue::List {
5112                element_type,
5113                values,
5114            } => Self::List(GraphCollectionValueDto {
5115                element_type: element_type.into(),
5116                values: values.into_iter().map(Self::from).collect(),
5117            }),
5118            GraphResultValue::Array {
5119                element_type,
5120                values,
5121            } => Self::Array(GraphCollectionValueDto {
5122                element_type: element_type.into(),
5123                values: values.into_iter().map(Self::from).collect(),
5124            }),
5125            GraphResultValue::Struct(values) => Self::Struct(named_graph_values(values)),
5126            GraphResultValue::Node(value) => Self::Node(value.into()),
5127            GraphResultValue::Rel(value) => Self::Rel(value.into()),
5128            GraphResultValue::RecursiveRel { nodes, rels } => {
5129                Self::RecursiveRel(GraphRecursiveRelDto {
5130                    nodes: nodes.into_iter().map(Into::into).collect(),
5131                    rels: rels.into_iter().map(Into::into).collect(),
5132                })
5133            }
5134            GraphResultValue::Map {
5135                key_type,
5136                value_type,
5137                entries,
5138            } => Self::Map(GraphMapValueDto {
5139                key_type: key_type.into(),
5140                value_type: value_type.into(),
5141                entries: entries
5142                    .into_iter()
5143                    .map(|(key, value)| GraphMapEntryDto {
5144                        key: key.into(),
5145                        value: value.into(),
5146                    })
5147                    .collect(),
5148            }),
5149            GraphResultValue::Union { variants, value } => Self::Union(GraphUnionValueDto {
5150                variants: named_graph_logical_types(variants),
5151                value: Box::new(Self::from(*value)),
5152            }),
5153            GraphResultValue::Uuid(value) => Self::Uuid(value),
5154            GraphResultValue::Decimal(value) => Self::Decimal(value),
5155        }
5156    }
5157}
5158
5159#[cfg(feature = "graph")]
5160fn named_graph_values(values: Vec<(String, GraphResultValue)>) -> Vec<GraphNamedValueDto> {
5161    values
5162        .into_iter()
5163        .map(|(name, value)| GraphNamedValueDto {
5164            name,
5165            value: value.into(),
5166        })
5167        .collect()
5168}
5169
5170#[cfg(feature = "graph")]
5171fn named_graph_logical_types(
5172    values: Vec<(String, GraphLogicalType)>,
5173) -> Vec<GraphNamedLogicalTypeDto> {
5174    values
5175        .into_iter()
5176        .map(|(name, logical_type)| GraphNamedLogicalTypeDto {
5177            name,
5178            logical_type: logical_type.into(),
5179        })
5180        .collect()
5181}
5182
5183#[cfg(feature = "graph")]
5184#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5185pub struct GraphColumnDto {
5186    pub name: String,
5187    pub logical_type: GraphLogicalTypeDto,
5188}
5189
5190#[cfg(feature = "graph")]
5191impl From<GraphColumn> for GraphColumnDto {
5192    fn from(value: GraphColumn) -> Self {
5193        Self {
5194            name: value.name,
5195            logical_type: value.logical_type.into(),
5196        }
5197    }
5198}
5199
5200#[cfg(feature = "graph")]
5201#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5202pub struct GraphQueryResponse {
5203    pub columns: Vec<GraphColumnDto>,
5204    pub rows: Vec<Vec<GraphResultValueDto>>,
5205    pub applied_index: LogIndex,
5206    pub hash: LogHash,
5207}
5208
5209#[cfg(feature = "graph")]
5210impl From<GraphQueryResult> for GraphQueryResponse {
5211    fn from(value: GraphQueryResult) -> Self {
5212        Self {
5213            columns: value.columns.into_iter().map(Into::into).collect(),
5214            rows: value
5215                .rows
5216                .into_iter()
5217                .map(|row| row.into_iter().map(Into::into).collect())
5218                .collect(),
5219            applied_index: value.applied_index,
5220            hash: value.hash,
5221        }
5222    }
5223}
5224
5225#[cfg(feature = "graph")]
5226#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5227#[serde(deny_unknown_fields)]
5228pub struct GraphPutDocumentRequest {
5229    pub request_id: String,
5230    pub id: String,
5231    pub value: GraphValueDto,
5232}
5233
5234#[cfg(feature = "graph")]
5235#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5236#[serde(deny_unknown_fields)]
5237pub struct GraphDeleteDocumentRequest {
5238    pub request_id: String,
5239    pub id: String,
5240}
5241
5242#[cfg(feature = "graph")]
5243#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5244#[serde(deny_unknown_fields)]
5245pub struct GraphGetDocumentRequest {
5246    pub id: String,
5247    pub consistency: Option<ReadConsistency>,
5248}
5249
5250#[cfg(feature = "graph")]
5251#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5252#[serde(tag = "operation", rename_all = "snake_case")]
5253pub enum GraphMutationResultDto {
5254    PutDocument { created: bool },
5255    DeleteDocument { existed: bool },
5256}
5257
5258#[cfg(feature = "graph")]
5259#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5260pub struct GraphMutationResponse {
5261    pub applied_index: LogIndex,
5262    pub hash: LogHash,
5263    pub result: GraphMutationResultDto,
5264}
5265
5266#[cfg(feature = "graph")]
5267#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5268pub struct GraphGetDocumentResponse {
5269    pub value: Option<GraphValueDto>,
5270    pub applied_index: LogIndex,
5271    pub hash: LogHash,
5272}
5273
5274#[cfg(feature = "kv")]
5275#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5276#[serde(deny_unknown_fields)]
5277pub struct KvPutRequest {
5278    pub request_id: String,
5279    pub key: String,
5280    pub value: String,
5281}
5282
5283#[cfg(feature = "kv")]
5284#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5285#[serde(deny_unknown_fields)]
5286pub struct KvDeleteRequest {
5287    pub request_id: String,
5288    pub key: String,
5289}
5290
5291#[cfg(feature = "kv")]
5292#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5293#[serde(deny_unknown_fields)]
5294pub struct KvGetRequest {
5295    pub key: String,
5296    pub consistency: Option<ReadConsistency>,
5297}
5298
5299#[cfg(feature = "kv")]
5300#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5301#[serde(deny_unknown_fields)]
5302pub struct KvScanRequest {
5303    pub start: Option<String>,
5304    pub end: Option<String>,
5305    pub prefix: Option<String>,
5306    pub cursor: Option<String>,
5307    pub limit: Option<usize>,
5308    pub consistency: Option<ReadConsistency>,
5309}
5310
5311#[cfg(feature = "kv")]
5312#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5313#[serde(tag = "operation", rename_all = "snake_case")]
5314pub enum KvMutationResultDto {
5315    Put { replaced: bool },
5316    Delete { existed: bool },
5317}
5318
5319#[cfg(feature = "kv")]
5320#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5321pub struct KvMutationResponse {
5322    pub applied_index: LogIndex,
5323    pub hash: LogHash,
5324    pub result: KvMutationResultDto,
5325}
5326
5327#[cfg(feature = "kv")]
5328#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5329pub struct KvGetResponse {
5330    pub value: Option<String>,
5331    pub applied_index: LogIndex,
5332    pub hash: LogHash,
5333}
5334
5335#[cfg(feature = "kv")]
5336#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5337pub struct KvScanEntryDto {
5338    pub key: String,
5339    pub value: String,
5340}
5341
5342#[cfg(feature = "kv")]
5343#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5344pub struct KvScanResponse {
5345    pub entries: Vec<KvScanEntryDto>,
5346    pub next_cursor: Option<String>,
5347    pub applied_index: LogIndex,
5348    pub hash: LogHash,
5349}
5350
5351#[cfg(feature = "graph")]
5352fn graph_mutation_response(outcome: GraphMutationOutcome) -> GraphMutationResponse {
5353    GraphMutationResponse {
5354        applied_index: outcome.applied_index(),
5355        hash: outcome.hash(),
5356        result: match outcome.result() {
5357            GraphCommandResultV1::PutDocument { created } => {
5358                GraphMutationResultDto::PutDocument { created: *created }
5359            }
5360            GraphCommandResultV1::DeleteDocument { existed } => {
5361                GraphMutationResultDto::DeleteDocument { existed: *existed }
5362            }
5363        },
5364    }
5365}
5366
5367#[cfg(feature = "kv")]
5368fn kv_mutation_response(outcome: KvMutationOutcome) -> KvMutationResponse {
5369    KvMutationResponse {
5370        applied_index: outcome.applied_index(),
5371        hash: outcome.hash(),
5372        result: match outcome.result() {
5373            KvCommandResultV1::Put { replaced } => KvMutationResultDto::Put {
5374                replaced: *replaced,
5375            },
5376            KvCommandResultV1::Delete { existed } => {
5377                KvMutationResultDto::Delete { existed: *existed }
5378            }
5379        },
5380    }
5381}
5382
5383#[cfg(feature = "kv")]
5384fn validate_kv_scan_required_index(
5385    result: &KvScanResult,
5386    required_index: Option<LogIndex>,
5387) -> Result<(), NodeError> {
5388    let applied_index = result.tip().applied_index();
5389    if required_index.is_some_and(|required| applied_index < required) {
5390        return Err(NodeError::Unavailable(format!(
5391            "local applied index {applied_index} has not reached {}",
5392            required_index.expect("checked above")
5393        )));
5394    }
5395    Ok(())
5396}
5397
5398#[cfg(feature = "graph")]
5399impl TryFrom<GraphValueDto> for GraphValueV1 {
5400    type Error = NodeError;
5401
5402    fn try_from(value: GraphValueDto) -> Result<Self, Self::Error> {
5403        match value {
5404            GraphValueDto::Null => Ok(Self::Null),
5405            GraphValueDto::Bool(value) => Ok(Self::Bool(value)),
5406            GraphValueDto::I64(value) => Ok(Self::I64(value)),
5407            GraphValueDto::U64(value) => Ok(Self::U64(value)),
5408            GraphValueDto::F64(value) => {
5409                Self::from_f64(value).map_err(|error| NodeError::InvalidRequest(error.to_string()))
5410            }
5411            GraphValueDto::String(value) => Ok(Self::String(value)),
5412            GraphValueDto::Bytes(value) => decode_base64("value", &value).map(Self::Bytes),
5413        }
5414    }
5415}
5416
5417#[cfg(feature = "graph")]
5418impl From<GraphValueV1> for GraphValueDto {
5419    fn from(value: GraphValueV1) -> Self {
5420        match value {
5421            GraphValueV1::Null => Self::Null,
5422            GraphValueV1::Bool(value) => Self::Bool(value),
5423            GraphValueV1::I64(value) => Self::I64(value),
5424            GraphValueV1::U64(value) => Self::U64(value),
5425            GraphValueV1::F64(value) => Self::F64(value.get()),
5426            GraphValueV1::String(value) => Self::String(value),
5427            GraphValueV1::Bytes(value) => Self::Bytes(encode_base64(&value)),
5428        }
5429    }
5430}
5431
5432#[cfg(any(feature = "graph", feature = "kv"))]
5433fn encode_base64(bytes: &[u8]) -> String {
5434    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
5435    let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4);
5436    for chunk in bytes.chunks(3) {
5437        let first = chunk[0];
5438        let second = chunk.get(1).copied().unwrap_or(0);
5439        let third = chunk.get(2).copied().unwrap_or(0);
5440        encoded.push(ALPHABET[usize::from(first >> 2)] as char);
5441        encoded.push(ALPHABET[usize::from(((first & 0x03) << 4) | (second >> 4))] as char);
5442        if chunk.len() > 1 {
5443            encoded.push(ALPHABET[usize::from(((second & 0x0f) << 2) | (third >> 6))] as char);
5444        } else {
5445            encoded.push('=');
5446        }
5447        if chunk.len() > 2 {
5448            encoded.push(ALPHABET[usize::from(third & 0x3f)] as char);
5449        } else {
5450            encoded.push('=');
5451        }
5452    }
5453    encoded
5454}
5455
5456#[cfg(any(feature = "graph", feature = "kv"))]
5457fn decode_base64(field: &str, encoded: &str) -> Result<Vec<u8>, NodeError> {
5458    fn sextet(byte: u8) -> Option<u8> {
5459        match byte {
5460            b'A'..=b'Z' => Some(byte - b'A'),
5461            b'a'..=b'z' => Some(byte - b'a' + 26),
5462            b'0'..=b'9' => Some(byte - b'0' + 52),
5463            b'+' => Some(62),
5464            b'/' => Some(63),
5465            _ => None,
5466        }
5467    }
5468
5469    let bytes = encoded.as_bytes();
5470    if !bytes.len().is_multiple_of(4) {
5471        return Err(NodeError::InvalidRequest(format!(
5472            "{field} must be canonical padded base64"
5473        )));
5474    }
5475    let mut decoded = Vec::with_capacity(bytes.len() / 4 * 3);
5476    for (chunk_index, chunk) in bytes.chunks_exact(4).enumerate() {
5477        let last = chunk_index + 1 == bytes.len() / 4;
5478        let first = sextet(chunk[0]);
5479        let second = sextet(chunk[1]);
5480        let third = (chunk[2] != b'=').then(|| sextet(chunk[2])).flatten();
5481        let fourth = (chunk[3] != b'=').then(|| sextet(chunk[3])).flatten();
5482        let has_padding = chunk[2] == b'=' || chunk[3] == b'=';
5483        if first.is_none()
5484            || second.is_none()
5485            || (chunk[2] != b'=' && third.is_none())
5486            || (chunk[3] != b'=' && fourth.is_none())
5487            || (!last && has_padding)
5488            || (chunk[2] == b'=' && chunk[3] != b'=')
5489        {
5490            return Err(NodeError::InvalidRequest(format!(
5491                "{field} must be canonical padded base64"
5492            )));
5493        }
5494        let first = first.unwrap();
5495        let second = second.unwrap();
5496        decoded.push((first << 2) | (second >> 4));
5497        if let Some(third) = third {
5498            decoded.push((second << 4) | (third >> 2));
5499            if let Some(fourth) = fourth {
5500                decoded.push((third << 6) | fourth);
5501            } else if third & 0x03 != 0 {
5502                return Err(NodeError::InvalidRequest(format!(
5503                    "{field} must be canonical padded base64"
5504                )));
5505            }
5506        } else if second & 0x0f != 0 {
5507            return Err(NodeError::InvalidRequest(format!(
5508                "{field} must be canonical padded base64"
5509            )));
5510        }
5511    }
5512    Ok(decoded)
5513}
5514
5515enum Materializer {
5516    #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5517    Unavailable,
5518    #[cfg(feature = "sql")]
5519    Sql(Box<SqliteStateMachine>),
5520    #[cfg(feature = "graph")]
5521    Graph(Arc<LadybugStateMachine>),
5522    #[cfg(feature = "kv")]
5523    Kv(Arc<RedbStateMachine>),
5524}
5525
5526#[cfg(any(feature = "sql", feature = "kv"))]
5527fn quarantine_materializer(data_dir: &Path, directory: &str) -> Result<(), NodeError> {
5528    static SEQUENCE: AtomicUsize = AtomicUsize::new(0);
5529    let source = data_dir.join(directory);
5530    if !source.exists() {
5531        return Ok(());
5532    }
5533    loop {
5534        let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
5535        let target = data_dir.join(format!(
5536            "{directory}.quarantine-{}-{sequence}",
5537            std::process::id()
5538        ));
5539        match fs::rename(&source, target) {
5540            Ok(()) => return Ok(()),
5541            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
5542            Err(error) => return Err(NodeError::Storage(error.to_string())),
5543        }
5544    }
5545}
5546
5547#[cfg(feature = "sql")]
5548struct SqlMaterializerGuard<'a>(MutexGuard<'a, Materializer>);
5549
5550#[cfg(feature = "sql")]
5551impl std::ops::Deref for SqlMaterializerGuard<'_> {
5552    type Target = SqliteStateMachine;
5553
5554    fn deref(&self) -> &Self::Target {
5555        match &*self.0 {
5556            Materializer::Sql(state) => state,
5557            #[cfg(feature = "graph")]
5558            Materializer::Graph(_) => unreachable!("SQL guard validated the materializer profile"),
5559            #[cfg(feature = "kv")]
5560            Materializer::Kv(_) => unreachable!("SQL guard validated the materializer profile"),
5561        }
5562    }
5563}
5564
5565impl Materializer {
5566    fn close_for_handoff(self) -> Result<(), String> {
5567        match self {
5568            #[cfg(feature = "sql")]
5569            Self::Sql(state) => state.close_for_handoff().map_err(|error| error.to_string()),
5570            #[cfg(feature = "graph")]
5571            Self::Graph(_) => Ok(()),
5572            #[cfg(feature = "kv")]
5573            Self::Kv(_) => Ok(()),
5574            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5575            Self::Unavailable => Ok(()),
5576        }
5577    }
5578
5579    fn ensure_profile_available(profile: ExecutionProfile) -> Result<(), NodeError> {
5580        if execution_profile_compiled(profile) {
5581            Ok(())
5582        } else {
5583            Err(NodeError::Unavailable(format!(
5584                "{} execution profile is not compiled in",
5585                profile.as_str()
5586            )))
5587        }
5588    }
5589
5590    #[cfg_attr(not(feature = "sql"), allow(unused_variables))]
5591    fn open(
5592        config: &NodeConfig,
5593        configuration_state: &ConfigurationState,
5594        recovery_anchor: Option<&RecoveryAnchor>,
5595    ) -> Result<Self, NodeError> {
5596        match config.execution_profile() {
5597            ExecutionProfile::Sqlite => {
5598                #[cfg(feature = "sql")]
5599                {
5600                    let path = config.data_dir().join("sqlite/db.sqlite");
5601                    let open = || {
5602                        rhiza_sql::cleanup_empty_wal_sidecars_after_owner_fence(&path)?;
5603                        SqliteStateMachine::open_with_configuration(
5604                            &path,
5605                            config.cluster_id(),
5606                            config.node_id(),
5607                            config.epoch(),
5608                            configuration_state.clone(),
5609                        )
5610                    };
5611                    let state = match open() {
5612                        Ok(state) => state,
5613                        Err(open_error) => match recovery_anchor {
5614                            Some(anchor) => {
5615                                eprintln!(
5616                                    "SQL materializer strict reopen failed before snapshot recovery: {open_error}"
5617                                );
5618                                return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())));
5619                            }
5620                            None => {
5621                                quarantine_materializer(config.data_dir(), "sqlite")?;
5622                                open().map_err(|error| NodeError::Storage(error.to_string()))?
5623                            }
5624                        },
5625                    };
5626                    Ok(Self::Sql(Box::new(state)))
5627                }
5628                #[cfg(not(feature = "sql"))]
5629                Err(NodeError::Unavailable(
5630                    "sql execution profile is not compiled in".into(),
5631                ))
5632            }
5633            ExecutionProfile::Graph => {
5634                #[cfg(feature = "graph")]
5635                {
5636                    LadybugStateMachine::open(
5637                        config.data_dir().join("ladybug/graph.lbug"),
5638                        config.cluster_id(),
5639                        config.node_id(),
5640                        config.epoch(),
5641                        configuration_state.config_id(),
5642                    )
5643                    .map(Arc::new)
5644                    .map(Self::Graph)
5645                    .map_err(|error| NodeError::Storage(error.to_string()))
5646                }
5647                #[cfg(not(feature = "graph"))]
5648                Err(NodeError::Unavailable(
5649                    "graph execution profile is not compiled in".into(),
5650                ))
5651            }
5652            ExecutionProfile::Kv => {
5653                #[cfg(feature = "kv")]
5654                {
5655                    let open = || {
5656                        RedbStateMachine::open(
5657                            config.data_dir().join("kv/data.redb"),
5658                            config.cluster_id(),
5659                            config.node_id(),
5660                            config.epoch(),
5661                            configuration_state.config_id(),
5662                        )
5663                    };
5664                    let state = match open() {
5665                        Ok(state) => state,
5666                        Err(_) => match recovery_anchor {
5667                            Some(anchor) => {
5668                                return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())))
5669                            }
5670                            _ => {
5671                                quarantine_materializer(config.data_dir(), "kv")?;
5672                                open().map_err(|error| NodeError::Storage(error.to_string()))?
5673                            }
5674                        },
5675                    };
5676                    Ok(Self::Kv(Arc::new(state)))
5677                }
5678                #[cfg(not(feature = "kv"))]
5679                Err(NodeError::Unavailable(
5680                    "kv execution profile is not compiled in".into(),
5681                ))
5682            }
5683        }
5684    }
5685
5686    #[cfg_attr(not(feature = "sql"), allow(unused_variables))]
5687    fn open_detached(
5688        data_dir: &Path,
5689        profile: ExecutionProfile,
5690        cluster_id: &str,
5691        node_id: &str,
5692        epoch: u64,
5693        configuration_state: &ConfigurationState,
5694    ) -> Result<Self, NodeError> {
5695        Self::ensure_profile_available(profile)?;
5696        match profile {
5697            ExecutionProfile::Sqlite => {
5698                #[cfg(feature = "sql")]
5699                {
5700                    SqliteStateMachine::open_with_configuration(
5701                        data_dir.join("sqlite/db.sqlite"),
5702                        cluster_id,
5703                        node_id,
5704                        epoch,
5705                        configuration_state.clone(),
5706                    )
5707                    .map(Box::new)
5708                    .map(Self::Sql)
5709                    .map_err(|error| NodeError::Storage(error.to_string()))
5710                }
5711                #[cfg(not(feature = "sql"))]
5712                Err(NodeError::Unavailable(
5713                    "sql execution profile is not compiled in".into(),
5714                ))
5715            }
5716            ExecutionProfile::Graph => {
5717                #[cfg(feature = "graph")]
5718                {
5719                    LadybugStateMachine::open(
5720                        data_dir.join("ladybug/graph.lbug"),
5721                        cluster_id,
5722                        node_id,
5723                        epoch,
5724                        configuration_state.config_id(),
5725                    )
5726                    .map(Arc::new)
5727                    .map(Self::Graph)
5728                    .map_err(|error| NodeError::Storage(error.to_string()))
5729                }
5730                #[cfg(not(feature = "graph"))]
5731                Err(NodeError::Unavailable(
5732                    "graph execution profile is not compiled in".into(),
5733                ))
5734            }
5735            ExecutionProfile::Kv => {
5736                #[cfg(feature = "kv")]
5737                {
5738                    RedbStateMachine::open(
5739                        data_dir.join("kv/data.redb"),
5740                        cluster_id,
5741                        node_id,
5742                        epoch,
5743                        configuration_state.config_id(),
5744                    )
5745                    .map(Arc::new)
5746                    .map(Self::Kv)
5747                    .map_err(|error| NodeError::Storage(error.to_string()))
5748                }
5749                #[cfg(not(feature = "kv"))]
5750                Err(NodeError::Unavailable(
5751                    "kv execution profile is not compiled in".into(),
5752                ))
5753            }
5754        }
5755    }
5756
5757    fn profile(&self) -> ExecutionProfile {
5758        match self {
5759            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5760            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5761            #[cfg(feature = "sql")]
5762            Self::Sql(_) => ExecutionProfile::Sqlite,
5763            #[cfg(feature = "graph")]
5764            Self::Graph(_) => ExecutionProfile::Graph,
5765            #[cfg(feature = "kv")]
5766            Self::Kv(_) => ExecutionProfile::Kv,
5767        }
5768    }
5769
5770    fn applied_index(&self) -> Result<LogIndex, String> {
5771        match self {
5772            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5773            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5774            #[cfg(feature = "sql")]
5775            Self::Sql(state) => state
5776                .applied_index_value()
5777                .map_err(|error| error.to_string()),
5778            #[cfg(feature = "graph")]
5779            Self::Graph(state) => state.applied_index().map_err(|error| error.to_string()),
5780            #[cfg(feature = "kv")]
5781            Self::Kv(state) => state.applied_index().map_err(|error| error.to_string()),
5782        }
5783    }
5784
5785    fn applied_hash(&self) -> Result<LogHash, String> {
5786        match self {
5787            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5788            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5789            #[cfg(feature = "sql")]
5790            Self::Sql(state) => state
5791                .applied_hash_value()
5792                .map_err(|error| error.to_string()),
5793            #[cfg(feature = "graph")]
5794            Self::Graph(state) => state.applied_hash().map_err(|error| error.to_string()),
5795            #[cfg(feature = "kv")]
5796            Self::Kv(state) => state.applied_hash().map_err(|error| error.to_string()),
5797        }
5798    }
5799
5800    fn applied_tip(&self) -> Result<LogAnchor, String> {
5801        match self {
5802            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5803            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5804            #[cfg(feature = "sql")]
5805            Self::Sql(state) => state
5806                .applied_tip()
5807                .map(|tip| LogAnchor::new(tip.applied_index(), tip.applied_hash()))
5808                .map_err(|error| error.to_string()),
5809            #[cfg(feature = "graph")]
5810            Self::Graph(state) => state.applied_tip().map_err(|error| error.to_string()),
5811            #[cfg(feature = "kv")]
5812            Self::Kv(state) => state.applied_tip().map_err(|error| error.to_string()),
5813        }
5814    }
5815
5816    fn configuration_state(&self) -> Result<Option<ConfigurationState>, String> {
5817        match self {
5818            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5819            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5820            #[cfg(feature = "sql")]
5821            Self::Sql(state) => state
5822                .configuration_state_value()
5823                .map(Some)
5824                .map_err(|error| error.to_string()),
5825            #[cfg(feature = "graph")]
5826            Self::Graph(_) => Ok(None),
5827            #[cfg(feature = "kv")]
5828            Self::Kv(_) => Ok(None),
5829        }
5830    }
5831
5832    fn apply_entry(&self, entry: &LogEntry) -> Result<Option<SqlCommandResult>, String> {
5833        match self {
5834            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5835            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5836            #[cfg(feature = "sql")]
5837            Self::Sql(state) => state
5838                .apply_entry_with_result(entry)
5839                .map(|outcome| outcome.sql_result().cloned())
5840                .map_err(|error| error.to_string()),
5841            #[cfg(feature = "graph")]
5842            Self::Graph(state) => state
5843                .apply_entry(entry)
5844                .map(|_| None)
5845                .map_err(|error| error.to_string()),
5846            #[cfg(feature = "kv")]
5847            Self::Kv(state) => state
5848                .apply_entry(entry)
5849                .map(|_| None)
5850                .map_err(|error| error.to_string()),
5851        }
5852    }
5853}
5854
5855const READ_BARRIER_COALESCE_WINDOW: Duration = Duration::from_micros(50);
5856
5857struct ReadBarrierRounds {
5858    state: Mutex<ReadBarrierRoundsState>,
5859    collection_window: Duration,
5860}
5861
5862struct ReadBarrierRoundsState {
5863    tail: Option<Arc<ReadBarrierRound>>,
5864    next_generation: u64,
5865}
5866
5867struct ReadBarrierRound {
5868    #[cfg(test)]
5869    generation: u64,
5870    collection_deadline: Instant,
5871    predecessor: Mutex<Option<Arc<ReadBarrierRound>>>,
5872    phase: Mutex<ReadBarrierRoundPhase>,
5873    changed: Condvar,
5874}
5875
5876#[derive(Clone)]
5877enum ReadBarrierRoundPhase {
5878    Collecting,
5879    Running,
5880    Complete(Result<LogAnchor, NodeError>),
5881}
5882
5883struct ReadBarrierParticipant {
5884    round: Arc<ReadBarrierRound>,
5885    leader: bool,
5886}
5887
5888struct ReadBarrierPublication {
5889    round: Arc<ReadBarrierRound>,
5890    published: bool,
5891}
5892
5893impl ReadBarrierRounds {
5894    fn new(collection_window: Duration) -> Self {
5895        Self {
5896            state: Mutex::new(ReadBarrierRoundsState {
5897                tail: None,
5898                next_generation: 0,
5899            }),
5900            collection_window,
5901        }
5902    }
5903
5904    fn join(&self) -> Result<ReadBarrierParticipant, NodeError> {
5905        let mut state = self
5906            .state
5907            .lock()
5908            .map_err(|_| NodeError::Invariant("read barrier generation lock is poisoned".into()))?;
5909        if let Some(tail) = state.tail.as_ref() {
5910            let collecting = matches!(
5911                *tail.phase.lock().map_err(|_| {
5912                    NodeError::Invariant("read barrier round lock is poisoned".into())
5913                })?,
5914                ReadBarrierRoundPhase::Collecting
5915            );
5916            if collecting {
5917                return Ok(ReadBarrierParticipant {
5918                    round: Arc::clone(tail),
5919                    leader: false,
5920                });
5921            }
5922        }
5923
5924        let generation = state
5925            .next_generation
5926            .checked_add(1)
5927            .ok_or_else(|| NodeError::Invariant("read barrier generation is exhausted".into()))?;
5928        state.next_generation = generation;
5929        let collection_deadline = Instant::now()
5930            .checked_add(self.collection_window)
5931            .unwrap_or_else(Instant::now);
5932        let round = Arc::new(ReadBarrierRound {
5933            #[cfg(test)]
5934            generation,
5935            collection_deadline,
5936            predecessor: Mutex::new(state.tail.clone()),
5937            phase: Mutex::new(ReadBarrierRoundPhase::Collecting),
5938            changed: Condvar::new(),
5939        });
5940        state.tail = Some(Arc::clone(&round));
5941        Ok(ReadBarrierParticipant {
5942            round,
5943            leader: true,
5944        })
5945    }
5946
5947    fn cancel_waiters(&self) {
5948        let mut round = self.state.lock().ok().and_then(|state| state.tail.clone());
5949        while let Some(current) = round {
5950            if let Ok(_phase) = current.phase.lock() {
5951                current.changed.notify_all();
5952            }
5953            round = current
5954                .predecessor
5955                .lock()
5956                .ok()
5957                .and_then(|predecessor| predecessor.clone());
5958        }
5959    }
5960}
5961
5962impl ReadBarrierRound {
5963    fn wait_complete(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
5964        let mut phase = self
5965            .phase
5966            .lock()
5967            .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5968        loop {
5969            if matches!(*phase, ReadBarrierRoundPhase::Complete(_)) {
5970                return Ok(());
5971            }
5972            if cancelled.load(Ordering::Acquire) {
5973                return Err(NodeError::Unavailable(
5974                    "read barrier cancelled during shutdown".into(),
5975                ));
5976            }
5977            phase = self
5978                .changed
5979                .wait(phase)
5980                .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5981        }
5982    }
5983
5984    fn result(&self, cancelled: &AtomicBool) -> Result<LogAnchor, NodeError> {
5985        let mut phase = self
5986            .phase
5987            .lock()
5988            .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5989        loop {
5990            if let ReadBarrierRoundPhase::Complete(result) = &*phase {
5991                return result.clone();
5992            }
5993            if cancelled.load(Ordering::Acquire) {
5994                return Err(NodeError::Unavailable(
5995                    "read barrier cancelled during shutdown".into(),
5996                ));
5997            }
5998            phase = self
5999                .changed
6000                .wait(phase)
6001                .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
6002        }
6003    }
6004
6005    fn complete(&self, result: Result<LogAnchor, NodeError>) {
6006        if let Ok(mut phase) = self.phase.lock() {
6007            if !matches!(*phase, ReadBarrierRoundPhase::Complete(_)) {
6008                *phase = ReadBarrierRoundPhase::Complete(result);
6009            }
6010            self.changed.notify_all();
6011        } else {
6012            self.changed.notify_all();
6013        }
6014    }
6015}
6016
6017impl ReadBarrierParticipant {
6018    #[cfg(test)]
6019    fn generation(&self) -> u64 {
6020        self.round.generation
6021    }
6022
6023    #[cfg(test)]
6024    fn is_leader(&self) -> bool {
6025        self.leader
6026    }
6027
6028    fn publication(&self) -> Option<ReadBarrierPublication> {
6029        self.leader.then(|| ReadBarrierPublication {
6030            round: Arc::clone(&self.round),
6031            published: false,
6032        })
6033    }
6034
6035    fn wait(&self, cancelled: &AtomicBool) -> Result<LogAnchor, NodeError> {
6036        self.round.result(cancelled)
6037    }
6038}
6039
6040impl ReadBarrierPublication {
6041    fn wait_turn(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
6042        let predecessor = self
6043            .round
6044            .predecessor
6045            .lock()
6046            .map_err(|_| NodeError::Invariant("read barrier predecessor lock is poisoned".into()))?
6047            .clone();
6048        if let Some(predecessor) = predecessor {
6049            predecessor.wait_complete(cancelled)?;
6050            self.round
6051                .predecessor
6052                .lock()
6053                .map_err(|_| {
6054                    NodeError::Invariant("read barrier predecessor lock is poisoned".into())
6055                })?
6056                .take();
6057        }
6058
6059        let mut phase = self
6060            .round
6061            .phase
6062            .lock()
6063            .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
6064        loop {
6065            if cancelled.load(Ordering::Acquire) {
6066                return Err(NodeError::Unavailable(
6067                    "read barrier cancelled during shutdown".into(),
6068                ));
6069            }
6070            if !matches!(*phase, ReadBarrierRoundPhase::Collecting) {
6071                return Err(NodeError::Invariant(
6072                    "read barrier leader left the collecting phase early".into(),
6073                ));
6074            }
6075            let now = Instant::now();
6076            if now >= self.round.collection_deadline {
6077                return Ok(());
6078            }
6079            let wait = self
6080                .round
6081                .collection_deadline
6082                .saturating_duration_since(now);
6083            let (next, _) =
6084                self.round.changed.wait_timeout(phase, wait).map_err(|_| {
6085                    NodeError::Invariant("read barrier round lock is poisoned".into())
6086                })?;
6087            phase = next;
6088        }
6089    }
6090
6091    fn start(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
6092        let mut phase = self
6093            .round
6094            .phase
6095            .lock()
6096            .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
6097        if cancelled.load(Ordering::Acquire) {
6098            return Err(NodeError::Unavailable(
6099                "read barrier cancelled during shutdown".into(),
6100            ));
6101        }
6102        if !matches!(*phase, ReadBarrierRoundPhase::Collecting) {
6103            return Err(NodeError::Invariant(
6104                "read barrier generation cannot start twice".into(),
6105            ));
6106        }
6107        *phase = ReadBarrierRoundPhase::Running;
6108        Ok(())
6109    }
6110
6111    fn publish(&mut self, result: Result<LogAnchor, NodeError>) {
6112        self.round.complete(result);
6113        self.published = true;
6114    }
6115}
6116
6117impl Drop for ReadBarrierPublication {
6118    fn drop(&mut self) {
6119        if !self.published {
6120            self.round.complete(Err(NodeError::Unavailable(
6121                "read barrier generation leader terminated".into(),
6122            )));
6123        }
6124    }
6125}
6126
6127#[cfg(all(test, feature = "kv"))]
6128type KvGroupCommitAfterExecuteHook = Arc<dyn Fn(&NodeRuntime) + Send + Sync>;
6129
6130pub struct NodeRuntime {
6131    config: NodeConfig,
6132    consensus: Arc<ThreeNodeConsensus>,
6133    log_store: FileLogStore,
6134    materializer: Mutex<Materializer>,
6135    commit: Mutex<()>,
6136    #[cfg(feature = "sql")]
6137    sql_group_commit: SqlGroupCommitQueue,
6138    #[cfg(feature = "kv")]
6139    kv_group_commit: KvGroupCommitQueue,
6140    read_barriers: ReadBarrierRounds,
6141    checkpointing: AtomicBool,
6142    operation_cancelled: AtomicBool,
6143    operation_cancelled_notify: tokio::sync::Notify,
6144    ready: AtomicBool,
6145    fatal: AtomicBool,
6146    fatal_reason: Mutex<Option<String>>,
6147    #[cfg(test)]
6148    materialized_tip_checks: AtomicUsize,
6149    #[cfg(all(test, feature = "sql"))]
6150    sql_group_commit_before_execute_hook: Option<Arc<dyn Fn() + Send + Sync>>,
6151    #[cfg(all(test, feature = "kv"))]
6152    kv_group_commit_before_execute_hook: Option<Arc<dyn Fn() + Send + Sync>>,
6153    #[cfg(all(test, feature = "kv"))]
6154    kv_group_commit_after_execute_hook: Option<KvGroupCommitAfterExecuteHook>,
6155    _data_root_lock: fs::File,
6156}
6157
6158#[cfg(feature = "sql")]
6159struct ExecutedPayload {
6160    response: WriteResponse,
6161    sql_result: Option<SqlCommandResult>,
6162}
6163
6164#[cfg(feature = "sql")]
6165trait SqlWritePhaseProfile {
6166    type Mark;
6167
6168    fn mark(&self) -> Self::Mark;
6169    fn commit_lock_acquired(&mut self);
6170    fn add_precheck_classification(&mut self, mark: Self::Mark);
6171    fn add_qwal_prepare(&mut self, mark: Self::Mark);
6172    fn add_consensus_propose(&mut self, mark: Self::Mark);
6173    fn add_local_qlog_mirror_append(&mut self, mark: Self::Mark);
6174    fn add_sql_materializer_apply(&mut self, mark: Self::Mark);
6175    fn record_success(&mut self, batch_member_count: usize);
6176}
6177
6178#[cfg(feature = "sql")]
6179struct DisabledSqlWritePhaseProfile;
6180
6181#[cfg(feature = "sql")]
6182impl SqlWritePhaseProfile for DisabledSqlWritePhaseProfile {
6183    type Mark = ();
6184
6185    #[inline]
6186    fn mark(&self) {}
6187
6188    #[inline]
6189    fn commit_lock_acquired(&mut self) {}
6190
6191    #[inline]
6192    fn add_precheck_classification(&mut self, (): ()) {}
6193
6194    #[inline]
6195    fn add_qwal_prepare(&mut self, (): ()) {}
6196
6197    #[inline]
6198    fn add_consensus_propose(&mut self, (): ()) {}
6199
6200    #[inline]
6201    fn add_local_qlog_mirror_append(&mut self, (): ()) {}
6202
6203    #[inline]
6204    fn add_sql_materializer_apply(&mut self, (): ()) {}
6205
6206    #[inline]
6207    fn record_success(&mut self, _batch_member_count: usize) {}
6208}
6209
6210#[cfg(feature = "sql")]
6211struct EnabledSqlWritePhaseProfile {
6212    observer: SqlWriteProfiler,
6213    service_started: Instant,
6214    commit_lock_wait: Duration,
6215    precheck_classification: Duration,
6216    qwal_prepare: Duration,
6217    consensus_propose: Duration,
6218    local_qlog_mirror_append: Duration,
6219    sql_materializer_apply: Duration,
6220}
6221
6222#[cfg(feature = "sql")]
6223impl EnabledSqlWritePhaseProfile {
6224    fn new(observer: SqlWriteProfiler) -> Self {
6225        Self {
6226            observer,
6227            service_started: Instant::now(),
6228            commit_lock_wait: Duration::ZERO,
6229            precheck_classification: Duration::ZERO,
6230            qwal_prepare: Duration::ZERO,
6231            consensus_propose: Duration::ZERO,
6232            local_qlog_mirror_append: Duration::ZERO,
6233            sql_materializer_apply: Duration::ZERO,
6234        }
6235    }
6236
6237    fn reset(&mut self) {
6238        self.service_started = Instant::now();
6239        self.commit_lock_wait = Duration::ZERO;
6240        self.precheck_classification = Duration::ZERO;
6241        self.qwal_prepare = Duration::ZERO;
6242        self.consensus_propose = Duration::ZERO;
6243        self.local_qlog_mirror_append = Duration::ZERO;
6244        self.sql_materializer_apply = Duration::ZERO;
6245    }
6246}
6247
6248#[cfg(feature = "sql")]
6249impl SqlWritePhaseProfile for EnabledSqlWritePhaseProfile {
6250    type Mark = Instant;
6251
6252    #[inline]
6253    fn mark(&self) -> Instant {
6254        Instant::now()
6255    }
6256
6257    fn commit_lock_acquired(&mut self) {
6258        self.commit_lock_wait = self.service_started.elapsed();
6259    }
6260
6261    fn add_precheck_classification(&mut self, mark: Instant) {
6262        self.precheck_classification += mark.elapsed();
6263    }
6264
6265    fn add_qwal_prepare(&mut self, mark: Instant) {
6266        self.qwal_prepare += mark.elapsed();
6267    }
6268
6269    fn add_consensus_propose(&mut self, mark: Instant) {
6270        self.consensus_propose += mark.elapsed();
6271    }
6272
6273    fn add_local_qlog_mirror_append(&mut self, mark: Instant) {
6274        self.local_qlog_mirror_append += mark.elapsed();
6275    }
6276
6277    fn add_sql_materializer_apply(&mut self, mark: Instant) {
6278        self.sql_materializer_apply += mark.elapsed();
6279    }
6280
6281    fn record_success(&mut self, batch_member_count: usize) {
6282        let total_service_us = duration_as_u64_micros(self.service_started.elapsed());
6283        let commit_lock_wait_us = duration_as_u64_micros(self.commit_lock_wait);
6284        let precheck_classification_us = duration_as_u64_micros(self.precheck_classification);
6285        let qwal_prepare_us = duration_as_u64_micros(self.qwal_prepare);
6286        let consensus_propose_us = duration_as_u64_micros(self.consensus_propose);
6287        let local_qlog_mirror_append_us = duration_as_u64_micros(self.local_qlog_mirror_append);
6288        let sql_materializer_apply_us = duration_as_u64_micros(self.sql_materializer_apply);
6289        let named_us = commit_lock_wait_us
6290            .saturating_add(precheck_classification_us)
6291            .saturating_add(qwal_prepare_us)
6292            .saturating_add(consensus_propose_us)
6293            .saturating_add(local_qlog_mirror_append_us)
6294            .saturating_add(sql_materializer_apply_us);
6295        self.observer.record(SqlWriteProfileSample {
6296            batch_member_count,
6297            commit_lock_wait_us,
6298            precheck_classification_us,
6299            qwal_prepare_us,
6300            consensus_propose_us,
6301            local_qlog_mirror_append_us,
6302            sql_materializer_apply_us,
6303            response_other_total_us: total_service_us.saturating_sub(named_us),
6304            total_service_us,
6305        });
6306        self.reset();
6307    }
6308}
6309
6310#[cfg(feature = "sql")]
6311fn duration_as_u64_micros(duration: Duration) -> u64 {
6312    u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
6313}
6314
6315#[cfg(feature = "sql")]
6316type CheckedSqlRequest = Result<Option<(RequestOutcome, SqlCommandResult)>, NodeError>;
6317
6318#[cfg(feature = "sql")]
6319#[derive(Clone, Debug, Eq, PartialEq)]
6320pub struct VerifiedSnapshotPublication {
6321    anchor: RecoveryAnchor,
6322}
6323
6324impl fmt::Debug for NodeRuntime {
6325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6326        f.debug_struct("NodeRuntime")
6327            .field("config", &self.config)
6328            .field("ready", &self.ready.load(Ordering::Acquire))
6329            .field("fatal", &self.fatal.load(Ordering::Acquire))
6330            .finish_non_exhaustive()
6331    }
6332}
6333
6334impl NodeRuntime {
6335    pub fn open(
6336        config: NodeConfig,
6337        consensus: Arc<ThreeNodeConsensus>,
6338        peer_candidates: &[&dyn LogPeer],
6339    ) -> Result<Self, NodeError> {
6340        Self::open_cancellable(config, consensus, peer_candidates, &StartupIoContext::new())
6341    }
6342
6343    pub fn open_cancellable(
6344        config: NodeConfig,
6345        consensus: Arc<ThreeNodeConsensus>,
6346        peer_candidates: &[&dyn LogPeer],
6347        startup: &StartupIoContext,
6348    ) -> Result<Self, NodeError> {
6349        startup.check("runtime configuration validation")?;
6350        if config.ack_mode == AckMode::DrStrong {
6351            return Err(NodeError::UnsupportedAckMode(AckMode::DrStrong));
6352        }
6353        Materializer::ensure_profile_available(config.execution_profile())?;
6354        startup.check("runtime data directory creation")?;
6355        fs::create_dir_all(&config.data_dir)
6356            .map_err(|error| NodeError::Storage(error.to_string()))?;
6357        startup.check("runtime data lock acquisition")?;
6358        let lock_path = config.data_dir.join(".node.lock");
6359        let data_root_lock = fs::OpenOptions::new()
6360            .read(true)
6361            .write(true)
6362            .create(true)
6363            .truncate(false)
6364            .open(&lock_path)
6365            .map_err(|error| NodeError::Storage(error.to_string()))?;
6366        match data_root_lock.try_lock() {
6367            Ok(()) => {}
6368            Err(fs::TryLockError::WouldBlock) => {
6369                return Err(NodeError::DataRootLocked(config.data_dir.clone()));
6370            }
6371            Err(fs::TryLockError::Error(error)) => {
6372                return Err(NodeError::Storage(error.to_string()));
6373            }
6374        }
6375
6376        startup.check("qlog open and recovery")?;
6377        let log_store = FileLogStore::open_with_configuration(
6378            config.data_dir.join("consensus/log"),
6379            &config.cluster_id,
6380            config.epoch,
6381            config.log_initial_configuration.clone(),
6382        )
6383        .map_err(|error| NodeError::Storage(error.to_string()))?;
6384        let persisted_configuration = log_store
6385            .configuration_state()
6386            .map_err(|error| NodeError::Storage(error.to_string()))?;
6387        let recovery_anchor = log_store
6388            .logical_state()
6389            .map_err(|error| NodeError::Storage(error.to_string()))?
6390            .anchor;
6391        startup.check("materializer open and reconciliation")?;
6392        let materializer =
6393            Materializer::open(&config, &persisted_configuration, recovery_anchor.as_ref())?;
6394        reconcile_local_storage(&config, &log_store, &materializer)?;
6395        startup.check("peer recovery")?;
6396        recover_peer_candidates(
6397            &config,
6398            consensus.as_ref(),
6399            &log_store,
6400            &materializer,
6401            peer_candidates,
6402            startup,
6403        )?;
6404        recover_startup_decisions(
6405            &config,
6406            consensus.as_ref(),
6407            &log_store,
6408            &materializer,
6409            startup,
6410        )?;
6411        startup.check("runtime readiness publication")?;
6412
6413        Ok(Self {
6414            #[cfg(feature = "sql")]
6415            sql_group_commit: SqlGroupCommitQueue::new(config.sql_group_commit_queue_capacity),
6416            #[cfg(feature = "kv")]
6417            kv_group_commit: KvGroupCommitQueue::new(),
6418            config,
6419            consensus,
6420            log_store,
6421            materializer: Mutex::new(materializer),
6422            commit: Mutex::new(()),
6423            read_barriers: ReadBarrierRounds::new(READ_BARRIER_COALESCE_WINDOW),
6424            checkpointing: AtomicBool::new(false),
6425            operation_cancelled: AtomicBool::new(false),
6426            operation_cancelled_notify: tokio::sync::Notify::new(),
6427            ready: AtomicBool::new(true),
6428            fatal: AtomicBool::new(false),
6429            fatal_reason: Mutex::new(None),
6430            #[cfg(test)]
6431            materialized_tip_checks: AtomicUsize::new(0),
6432            #[cfg(all(test, feature = "sql"))]
6433            sql_group_commit_before_execute_hook: None,
6434            #[cfg(all(test, feature = "kv"))]
6435            kv_group_commit_before_execute_hook: None,
6436            #[cfg(all(test, feature = "kv"))]
6437            kv_group_commit_after_execute_hook: None,
6438            _data_root_lock: data_root_lock,
6439        })
6440    }
6441
6442    #[cfg(feature = "sql")]
6443    pub fn write(
6444        &self,
6445        request_id: &str,
6446        key: &str,
6447        value: &str,
6448    ) -> Result<WriteResponse, NodeError> {
6449        let payload = canonical_put(request_id, key, value)?;
6450        let _commit = self.lock_commit()?;
6451        self.execute_put_payload_locked(request_id, key, value, payload)
6452            .map(|outcome| outcome.response)
6453    }
6454
6455    #[cfg(feature = "graph")]
6456    pub fn mutate_graph(&self, command: GraphCommandV1) -> Result<GraphMutationOutcome, NodeError> {
6457        let payload = encode_replicated_graph_command(&command)
6458            .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6459        if payload.len() > MAX_COMMAND_BYTES {
6460            return Err(NodeError::InvalidRequest(format!(
6461                "command exceeds {MAX_COMMAND_BYTES} bytes"
6462            )));
6463        }
6464        let _commit = self.lock_commit()?;
6465        self.mutate_graph_payload_locked(&command, payload)
6466    }
6467
6468    /// Executes an ordered, non-atomic batch of graph mutations.
6469    ///
6470    /// Valid commands may share one QuePaxa log entry. Per-command conflicts remain isolated in
6471    /// the returned vector, whose order and length match `commands`. The whole vector is validated
6472    /// before the first write attempt, so an outer `Err` guarantees that nothing was attempted.
6473    #[cfg(feature = "graph")]
6474    #[cfg_attr(
6475        all(not(feature = "sql"), not(feature = "kv")),
6476        allow(unreachable_patterns)
6477    )]
6478    pub fn mutate_graph_batch(
6479        &self,
6480        commands: Vec<GraphCommandV1>,
6481    ) -> Result<Vec<Result<GraphMutationOutcome, NodeError>>, NodeError> {
6482        self.require_execution_profile(ExecutionProfile::Graph)?;
6483        validate_typed_batch_len(commands.len())?;
6484        let mut members = Vec::with_capacity(commands.len());
6485        for command in commands {
6486            let payload = encode_replicated_graph_command(&command)
6487                .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6488            validate_command_size(&payload)?;
6489            members.push(RuntimeBatchMember {
6490                #[cfg(feature = "sql")]
6491                request_id: command.request_id().to_owned(),
6492                payload,
6493                operation: QueuedOperation::Graph(command),
6494            });
6495        }
6496        Ok(self
6497            .execute_client_batch(members)
6498            .into_iter()
6499            .map(|result| {
6500                result.and_then(|response| match response {
6501                    ClientWriteResponse::Graph(outcome) => Ok(outcome),
6502                    _ => Err(NodeError::Invariant(
6503                        "graph batch returned a response for another profile".into(),
6504                    )),
6505                })
6506            })
6507            .collect())
6508    }
6509
6510    #[cfg(feature = "graph")]
6511    fn mutate_graph_payload_locked(
6512        &self,
6513        command: &GraphCommandV1,
6514        payload: Vec<u8>,
6515    ) -> Result<GraphMutationOutcome, NodeError> {
6516        self.ensure_ready()?;
6517        self.ensure_writes_active()?;
6518        if let Some(record) = self.check_graph_request(command.request_id(), &payload)? {
6519            return Ok(GraphMutationOutcome::from_record(record));
6520        }
6521        loop {
6522            let (last_index, last_hash) = self.ensure_materialized_tip()?;
6523            let slot = last_index.checked_add(1).ok_or_else(|| {
6524                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
6525            })?;
6526            let entry = self
6527                .consensus
6528                .propose_at_cancellable(
6529                    slot,
6530                    last_hash,
6531                    Command::new(CommandKind::Deterministic, payload.clone()),
6532                    &self.operation_cancelled,
6533                )
6534                .map_err(|error| self.map_consensus_error(error))?;
6535            self.persist_entry(&entry, slot, last_hash)?;
6536            if let Some(record) = self.check_graph_request(command.request_id(), &payload)? {
6537                return Ok(GraphMutationOutcome::from_record(record));
6538            }
6539            if entry.entry_type == EntryType::Command && entry.payload == payload {
6540                return Err(self.latch(NodeError::Invariant(
6541                    "committed graph request was not recorded by Ladybug".into(),
6542                )));
6543            }
6544        }
6545    }
6546
6547    #[cfg(feature = "graph")]
6548    pub fn get_graph_document(
6549        &self,
6550        id: &str,
6551        consistency: ReadConsistency,
6552    ) -> Result<GraphReadResponse, NodeError> {
6553        match consistency {
6554            ReadConsistency::Local => self.get_graph_document_local(id, None),
6555            ReadConsistency::AppliedIndex(required) => {
6556                self.get_graph_document_local(id, Some(required))
6557            }
6558            ReadConsistency::ReadBarrier => {
6559                let anchor = self.establish_read_barrier()?;
6560                self.validate_read_barrier_before_snapshot(anchor)?;
6561                let response = self.get_graph_document_local(id, Some(anchor.index()))?;
6562                self.validate_read_barrier_snapshot(
6563                    anchor,
6564                    LogAnchor::new(response.applied_index, response.hash),
6565                )?;
6566                Ok(response)
6567            }
6568        }
6569    }
6570
6571    #[cfg(feature = "graph")]
6572    pub fn query_graph(
6573        &self,
6574        statement: &str,
6575        parameters: &BTreeMap<String, GraphParameterValue>,
6576        consistency: ReadConsistency,
6577        max_rows: u32,
6578    ) -> Result<GraphQueryResult, NodeError> {
6579        if max_rows == 0 || max_rows > MAX_GRAPH_MAX_ROWS {
6580            return Err(NodeError::InvalidRequest(format!(
6581                "max_rows must be between 1 and {MAX_GRAPH_MAX_ROWS}"
6582            )));
6583        }
6584        match consistency {
6585            ReadConsistency::Local => self.query_graph_local(statement, parameters, None, max_rows),
6586            ReadConsistency::AppliedIndex(required) => {
6587                self.query_graph_local(statement, parameters, Some(required), max_rows)
6588            }
6589            ReadConsistency::ReadBarrier => {
6590                let anchor = self.establish_read_barrier()?;
6591                self.validate_read_barrier_before_snapshot(anchor)?;
6592                let result =
6593                    self.query_graph_local(statement, parameters, Some(anchor.index()), max_rows)?;
6594                self.validate_read_barrier_snapshot(
6595                    anchor,
6596                    LogAnchor::new(result.applied_index, result.hash),
6597                )?;
6598                Ok(result)
6599            }
6600        }
6601    }
6602
6603    #[cfg(feature = "kv")]
6604    pub fn mutate_kv(&self, command: KvCommandV1) -> Result<KvMutationOutcome, NodeError> {
6605        self.mutate_kv_batch(vec![command])?
6606            .into_iter()
6607            .next()
6608            .expect("one-member KV batch returns one result")
6609    }
6610
6611    /// Executes an ordered, non-atomic batch of KV mutations.
6612    ///
6613    /// Valid commands may share one QuePaxa log entry. Per-command conflicts remain isolated in
6614    /// the returned vector, whose order and length match `commands`. The whole vector is validated
6615    /// before the first write attempt, so an outer `Err` guarantees that nothing was attempted.
6616    #[cfg(feature = "kv")]
6617    #[cfg_attr(
6618        all(not(feature = "sql"), not(feature = "graph")),
6619        allow(unreachable_patterns)
6620    )]
6621    pub fn mutate_kv_batch(
6622        &self,
6623        commands: Vec<KvCommandV1>,
6624    ) -> Result<Vec<Result<KvMutationOutcome, NodeError>>, NodeError> {
6625        self.require_execution_profile(ExecutionProfile::Kv)?;
6626        validate_kv_batch_len(commands.len())?;
6627        let mut members = Vec::with_capacity(commands.len());
6628        for command in commands {
6629            let payload = encode_replicated_kv_command(&command)
6630                .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6631            validate_command_size(&payload)?;
6632            members.push(RuntimeBatchMember {
6633                #[cfg(feature = "sql")]
6634                request_id: command.request_id().to_owned(),
6635                payload,
6636                operation: QueuedOperation::Kv(command),
6637            });
6638        }
6639        Ok(self
6640            .execute_kv_group_commit(members)?
6641            .into_iter()
6642            .map(|result| {
6643                result.and_then(|response| match response {
6644                    ClientWriteResponse::Kv(outcome) => Ok(outcome),
6645                    _ => Err(NodeError::Invariant(
6646                        "KV batch returned a response for another profile".into(),
6647                    )),
6648                })
6649            })
6650            .collect())
6651    }
6652
6653    #[cfg(feature = "kv")]
6654    fn mutate_kv_payload_locked(
6655        &self,
6656        command: &KvCommandV1,
6657        payload: Vec<u8>,
6658    ) -> Result<KvMutationOutcome, NodeError> {
6659        self.ensure_ready()?;
6660        self.ensure_writes_active()?;
6661        if let Some(record) = self.check_kv_request(command.request_id(), &payload)? {
6662            return Ok(KvMutationOutcome::from_record(record));
6663        }
6664        loop {
6665            let (last_index, last_hash) = self.ensure_materialized_tip()?;
6666            let slot = last_index.checked_add(1).ok_or_else(|| {
6667                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
6668            })?;
6669            let entry = self
6670                .consensus
6671                .propose_at_cancellable(
6672                    slot,
6673                    last_hash,
6674                    Command::new(CommandKind::Deterministic, payload.clone()),
6675                    &self.operation_cancelled,
6676                )
6677                .map_err(|error| self.map_consensus_error(error))?;
6678            self.persist_entry(&entry, slot, last_hash)?;
6679            if let Some(record) = self.check_kv_request(command.request_id(), &payload)? {
6680                return Ok(KvMutationOutcome::from_record(record));
6681            }
6682            if entry.entry_type == EntryType::Command && entry.payload == payload {
6683                return Err(self.latch(NodeError::Invariant(
6684                    "committed KV request was not recorded by redb".into(),
6685                )));
6686            }
6687        }
6688    }
6689
6690    #[cfg(feature = "kv")]
6691    pub fn get_kv(
6692        &self,
6693        key: &[u8],
6694        consistency: ReadConsistency,
6695    ) -> Result<KvReadResponse, NodeError> {
6696        match consistency {
6697            ReadConsistency::Local => self.get_kv_local(key, None),
6698            ReadConsistency::AppliedIndex(required) => self.get_kv_local(key, Some(required)),
6699            ReadConsistency::ReadBarrier => {
6700                let anchor = self.establish_read_barrier()?;
6701                self.validate_read_barrier_before_snapshot(anchor)?;
6702                let response = self.get_kv_local(key, Some(anchor.index()))?;
6703                self.validate_read_barrier_snapshot(
6704                    anchor,
6705                    LogAnchor::new(response.applied_index, response.hash),
6706                )?;
6707                Ok(response)
6708            }
6709        }
6710    }
6711
6712    #[cfg(feature = "kv")]
6713    pub fn scan_kv_range(
6714        &self,
6715        start: &[u8],
6716        end: Option<&[u8]>,
6717        limit: usize,
6718        cursor: Option<&[u8]>,
6719        consistency: ReadConsistency,
6720    ) -> Result<KvScanResult, NodeError> {
6721        match consistency {
6722            ReadConsistency::Local => self.scan_kv_range_local(start, end, limit, cursor, None),
6723            ReadConsistency::AppliedIndex(required) => {
6724                self.scan_kv_range_local(start, end, limit, cursor, Some(required))
6725            }
6726            ReadConsistency::ReadBarrier => {
6727                let anchor = self.establish_read_barrier()?;
6728                self.validate_read_barrier_before_snapshot(anchor)?;
6729                let result =
6730                    self.scan_kv_range_local(start, end, limit, cursor, Some(anchor.index()))?;
6731                self.validate_read_barrier_snapshot(
6732                    anchor,
6733                    LogAnchor::new(result.tip().applied_index(), result.tip().applied_hash()),
6734                )?;
6735                Ok(result)
6736            }
6737        }
6738    }
6739
6740    #[cfg(feature = "kv")]
6741    pub fn scan_kv_prefix(
6742        &self,
6743        prefix: &[u8],
6744        limit: usize,
6745        cursor: Option<&[u8]>,
6746        consistency: ReadConsistency,
6747    ) -> Result<KvScanResult, NodeError> {
6748        match consistency {
6749            ReadConsistency::Local => self.scan_kv_prefix_local(prefix, limit, cursor, None),
6750            ReadConsistency::AppliedIndex(required) => {
6751                self.scan_kv_prefix_local(prefix, limit, cursor, Some(required))
6752            }
6753            ReadConsistency::ReadBarrier => {
6754                let anchor = self.establish_read_barrier()?;
6755                self.validate_read_barrier_before_snapshot(anchor)?;
6756                let result =
6757                    self.scan_kv_prefix_local(prefix, limit, cursor, Some(anchor.index()))?;
6758                self.validate_read_barrier_snapshot(
6759                    anchor,
6760                    LogAnchor::new(result.tip().applied_index(), result.tip().applied_hash()),
6761                )?;
6762                Ok(result)
6763            }
6764        }
6765    }
6766
6767    #[cfg(feature = "sql")]
6768    pub fn execute_sql(&self, command: SqlCommand) -> Result<WriteResponse, NodeError> {
6769        self.execute_sql_with_results(command)
6770            .map(|response| WriteResponse {
6771                applied_index: response.applied_index,
6772                hash: response.hash,
6773            })
6774    }
6775
6776    /// Executes an ordered, non-atomic batch of SQL commands.
6777    ///
6778    /// All successful, previously unseen members that fit the replicated command byte cap share
6779    /// one exact-base QWAL entry. Failed members are rolled back independently and later members
6780    /// continue. The aggregate canonical input is limited to [`MAX_COMMAND_BYTES`]. The whole
6781    /// vector is validated before the first write attempt, so an outer `Err` guarantees that
6782    /// nothing was attempted.
6783    #[cfg(feature = "sql")]
6784    pub fn execute_sql_batch(
6785        &self,
6786        commands: Vec<SqlCommand>,
6787    ) -> Result<Vec<Result<SqlExecuteResponse, NodeError>>, NodeError> {
6788        self.require_execution_profile(ExecutionProfile::Sqlite)?;
6789        validate_sql_batch_len(commands.len())?;
6790        let mut members = Vec::with_capacity(commands.len());
6791        let mut aggregate_encoded_bytes = 0_usize;
6792        for command in commands {
6793            validate_field(
6794                "request_id",
6795                &command.request_id,
6796                MAX_REQUEST_ID_BYTES,
6797                false,
6798            )?;
6799            let payload = encode_sql_command_with_index(&command)?;
6800            validate_command_size(&payload)?;
6801            aggregate_encoded_bytes = aggregate_encoded_bytes.saturating_add(payload.len());
6802            if aggregate_encoded_bytes > MAX_COMMAND_BYTES {
6803                return Err(NodeError::ResourceExhausted(format!(
6804                    "SQL write batch exceeds {MAX_COMMAND_BYTES} aggregate encoded bytes"
6805                )));
6806            }
6807            members.push(RuntimeBatchMember {
6808                request_id: command.request_id.clone(),
6809                payload,
6810                operation: QueuedOperation::Sql(command),
6811            });
6812        }
6813        let single_statement_batch = members.iter().all(|member| {
6814            matches!(
6815                &member.operation,
6816                QueuedOperation::Sql(command) if command.statements.len() == 1
6817            )
6818        });
6819        let results = if single_statement_batch {
6820            self.execute_sql_group_commit(members)?
6821        } else {
6822            self.execute_client_batch(members)
6823        };
6824        Ok(results
6825            .into_iter()
6826            .map(|result| {
6827                result.and_then(|response| match response {
6828                    ClientWriteResponse::Sql(response) => Ok(response),
6829                    _ => Err(NodeError::Invariant(
6830                        "SQL batch returned a response for another profile".into(),
6831                    )),
6832                })
6833            })
6834            .collect())
6835    }
6836
6837    #[cfg(feature = "sql")]
6838    fn execute_sql_with_results(
6839        &self,
6840        command: SqlCommand,
6841    ) -> Result<SqlExecuteResponse, NodeError> {
6842        self.execute_sql_batch(vec![command])?
6843            .into_iter()
6844            .next()
6845            .expect("one-member SQL batch returns one result")
6846    }
6847
6848    #[cfg(feature = "sql")]
6849    fn execute_sql_group_commit(&self, members: Vec<RuntimeBatchMember>) -> SqlGroupCommitResult {
6850        let (job, leader) = self
6851            .sql_group_commit
6852            .enqueue(members, &self.operation_cancelled)?;
6853        if leader {
6854            if let Some(observer) = self.config.sql_write_profiler.clone() {
6855                self.run_sql_group_commit_leader(&mut EnabledSqlWritePhaseProfile::new(observer));
6856            } else {
6857                self.run_sql_group_commit_leader(&mut DisabledSqlWritePhaseProfile);
6858            }
6859        }
6860        job.wait(&self.operation_cancelled)
6861    }
6862
6863    #[cfg(feature = "sql")]
6864    fn run_sql_group_commit_leader<P: SqlWritePhaseProfile>(&self, profile: &mut P) {
6865        let _commit = match self.lock_commit() {
6866            Ok(commit) => commit,
6867            Err(error) => {
6868                self.sql_group_commit.fail_pending(error);
6869                return;
6870            }
6871        };
6872        profile.commit_lock_acquired();
6873
6874        loop {
6875            if !self
6876                .sql_group_commit
6877                .collect_until_full_or_timeout(self.config.writer_batch_window())
6878            {
6879                break;
6880            }
6881            let Some(jobs) = self.sql_group_commit.drain_next_group() else {
6882                break;
6883            };
6884            if let Err(error) = self
6885                .ensure_ready()
6886                .and_then(|_| self.ensure_writes_active())
6887            {
6888                for job in &jobs {
6889                    job.publish(Err(error.clone()));
6890                }
6891                self.sql_group_commit.fail_pending(error);
6892                return;
6893            }
6894            let execution = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
6895                #[cfg(test)]
6896                if let Some(hook) = &self.sql_group_commit_before_execute_hook {
6897                    hook();
6898                }
6899                self.execute_sql_group_locked(&jobs, profile)
6900            }));
6901            let grouped_results = match execution {
6902                Ok(Ok(grouped_results)) => grouped_results,
6903                Ok(Err(error)) => {
6904                    let error = if matches!(error, NodeError::Unavailable(_)) {
6905                        error
6906                    } else {
6907                        self.latch(error)
6908                    };
6909                    for job in &jobs {
6910                        job.publish(Err(error.clone()));
6911                    }
6912                    self.sql_group_commit.fail_pending(error);
6913                    return;
6914                }
6915                Err(_) => {
6916                    let error =
6917                        self.latch(NodeError::Fatal("SQL group commit leader panicked".into()));
6918                    for job in &jobs {
6919                        job.publish(Err(error.clone()));
6920                    }
6921                    self.sql_group_commit.fail_pending(error);
6922                    return;
6923                }
6924            };
6925            for (job, results) in jobs.iter().zip(grouped_results) {
6926                job.publish(Ok(results));
6927            }
6928        }
6929    }
6930
6931    #[cfg(feature = "sql")]
6932    fn execute_sql_group_locked<P: SqlWritePhaseProfile>(
6933        &self,
6934        jobs: &[Arc<SqlGroupCommitJob>],
6935        profile: &mut P,
6936    ) -> Result<Vec<Vec<Result<ClientWriteResponse, NodeError>>>, NodeError> {
6937        if self.operation_cancelled.load(Ordering::Acquire) {
6938            return Err(NodeError::Unavailable(
6939                "SQL group commit cancelled during shutdown".into(),
6940            ));
6941        }
6942        let total_members = jobs.iter().map(|job| job.member_count).sum();
6943        if total_members == 0 || total_members > MAX_SQL_WRITE_BATCH_MEMBERS {
6944            return Err(NodeError::Invariant(format!(
6945                "SQL group commit drained {total_members} members outside 1..={MAX_SQL_WRITE_BATCH_MEMBERS}"
6946            )));
6947        }
6948        let mut members = Vec::with_capacity(total_members);
6949        for job in jobs {
6950            let job_members = job.take_members()?;
6951            if job_members.len() != job.member_count {
6952                return Err(NodeError::Invariant(
6953                    "SQL group commit job member count changed while queued".into(),
6954                ));
6955            }
6956            members.extend(job_members);
6957        }
6958        let results = self.execute_sql_client_batch_locked(&members, profile);
6959        if self.operation_cancelled.load(Ordering::Acquire) {
6960            return Err(NodeError::Unavailable(
6961                "SQL group commit cancelled during shutdown".into(),
6962            ));
6963        }
6964        if self.is_fatal() {
6965            return Err(NodeError::Fatal(
6966                self.fatal_reason()
6967                    .unwrap_or_else(|| "SQL group commit failed fatally".into()),
6968            ));
6969        }
6970        if results.len() != total_members {
6971            return Err(NodeError::Invariant(format!(
6972                "SQL group commit returned {} results for {total_members} members",
6973                results.len()
6974            )));
6975        }
6976        let mut results = results.into_iter();
6977        let grouped = jobs
6978            .iter()
6979            .map(|job| results.by_ref().take(job.member_count).collect::<Vec<_>>())
6980            .collect::<Vec<_>>();
6981        if results.next().is_some() || grouped.iter().map(Vec::len).sum::<usize>() != total_members
6982        {
6983            return Err(NodeError::Invariant(
6984                "SQL group commit result offsets were misaligned".into(),
6985            ));
6986        }
6987        Ok(grouped)
6988    }
6989
6990    #[cfg(feature = "sql")]
6991    fn execute_client_batch(
6992        &self,
6993        members: Vec<RuntimeBatchMember>,
6994    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
6995        if self.config.execution_profile != ExecutionProfile::Sqlite {
6996            return self.execute_profile_client_batch(members);
6997        }
6998        let is_single_statement_sql = members.iter().all(|member| {
6999            matches!(
7000                &member.operation,
7001                QueuedOperation::Sql(command) if command.statements.len() == 1
7002            )
7003        });
7004        if is_single_statement_sql {
7005            if let Some(observer) = self.config.sql_write_profiler.clone() {
7006                let mut profile = EnabledSqlWritePhaseProfile::new(observer);
7007                let _commit = match self.lock_commit() {
7008                    Ok(commit) => commit,
7009                    Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7010                };
7011                profile.commit_lock_acquired();
7012                if let Err(error) = self
7013                    .ensure_ready()
7014                    .and_then(|_| self.ensure_writes_active())
7015                {
7016                    return members.into_iter().map(|_| Err(error.clone())).collect();
7017                }
7018                return self.execute_sql_client_batch_locked(&members, &mut profile);
7019            }
7020        }
7021        let _commit = match self.lock_commit() {
7022            Ok(commit) => commit,
7023            Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7024        };
7025        if let Err(error) = self
7026            .ensure_ready()
7027            .and_then(|_| self.ensure_writes_active())
7028        {
7029            return members.into_iter().map(|_| Err(error.clone())).collect();
7030        }
7031
7032        if is_single_statement_sql {
7033            return self
7034                .execute_sql_client_batch_locked(&members, &mut DisabledSqlWritePhaseProfile);
7035        }
7036
7037        members
7038            .iter()
7039            .map(|member| self.execute_single_member_locked(member))
7040            .collect()
7041    }
7042
7043    #[cfg(feature = "sql")]
7044    fn execute_sql_client_batch_locked<P: SqlWritePhaseProfile>(
7045        &self,
7046        members: &[RuntimeBatchMember],
7047        profile: &mut P,
7048    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7049        let classification_mark = profile.mark();
7050        let mut results = vec![None; members.len()];
7051        let mut pending = Vec::new();
7052        let mut canonical_by_request: HashMap<String, Vec<usize>> = HashMap::new();
7053        let mut lookup_indices = Vec::with_capacity(members.len());
7054        let mut aliases = vec![None; members.len()];
7055        let mut blocked_by = vec![None; members.len()];
7056
7057        for (index, member) in members.iter().enumerate() {
7058            let QueuedOperation::Sql(command) = &member.operation else {
7059                unreachable!("SQL batch members were validated by the caller");
7060            };
7061            let canonicals = canonical_by_request
7062                .entry(command.request_id.clone())
7063                .or_default();
7064            if let Some(canonical) = canonicals
7065                .iter()
7066                .copied()
7067                .find(|canonical| members[*canonical].payload == member.payload)
7068            {
7069                aliases[index] = Some(canonical);
7070                continue;
7071            }
7072            blocked_by[index] = canonicals.last().copied();
7073            canonicals.push(index);
7074            lookup_indices.push(index);
7075        }
7076
7077        let preflight = self.check_sql_members_bulk(members, &lookup_indices);
7078        let preflight = match preflight {
7079            Ok(preflight) => preflight,
7080            Err(error) => {
7081                profile.add_precheck_classification(classification_mark);
7082                return members.iter().map(|_| Err(error.clone())).collect();
7083            }
7084        };
7085        for (index, lookup) in preflight {
7086            match lookup {
7087                Ok(Some((outcome, sql_result))) => {
7088                    results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7089                        write_response(outcome),
7090                        Some(sql_result),
7091                    ))));
7092                }
7093                Ok(None) => pending.push(index),
7094                Err(error) => results[index] = Some(Err(error)),
7095            }
7096        }
7097        profile.add_precheck_classification(classification_mark);
7098
7099        while !pending.is_empty() {
7100            let eligible = pending
7101                .iter()
7102                .copied()
7103                .filter(|index| {
7104                    blocked_by[*index].is_none_or(|predecessor| results[predecessor].is_some())
7105                })
7106                .take(MAX_SQL_WRITE_BATCH_MEMBERS)
7107                .collect::<Vec<_>>();
7108            if eligible.is_empty() {
7109                let error = self.latch(NodeError::Invariant(
7110                    "SQL writer batch has an unresolved duplicate dependency".into(),
7111                ));
7112                for index in pending.drain(..) {
7113                    results[index] = Some(Err(error.clone()));
7114                }
7115                break;
7116            }
7117
7118            let (last_index, last_hash) = match self.ensure_materialized_tip() {
7119                Ok(tip) => tip,
7120                Err(error) => {
7121                    for index in pending.drain(..) {
7122                        results[index] = Some(Err(error.clone()));
7123                    }
7124                    break;
7125                }
7126            };
7127            let mut attempt_count = eligible.len();
7128            let (proposal_payload, prepared_results) = loop {
7129                let attempted = &eligible[..attempt_count];
7130                let batch_members = attempted
7131                    .iter()
7132                    .map(|index| {
7133                        let QueuedOperation::Sql(command) = &members[*index].operation else {
7134                            unreachable!("SQL batch members were validated above");
7135                        };
7136                        SqlBatchMember {
7137                            command,
7138                            request_payload: &members[*index].payload,
7139                        }
7140                    })
7141                    .collect::<Vec<_>>();
7142                let preparation_mark = profile.mark();
7143                let preparation = self.lock_sqlite().and_then(|sqlite| {
7144                    sqlite
7145                        .prepare_sql_batch_effect(&batch_members, last_index, last_hash)
7146                        .map_err(|error| self.map_sqlite_error(error))
7147                });
7148                profile.add_qwal_prepare(preparation_mark);
7149                let preparation = match preparation {
7150                    Ok(preparation) => preparation,
7151                    Err(NodeError::ResourceExhausted(message)) if attempt_count > 1 => {
7152                        attempt_count = attempt_count.div_ceil(2);
7153                        let _ = message;
7154                        continue;
7155                    }
7156                    Err(NodeError::ResourceExhausted(message)) => {
7157                        results[attempted[0]] = Some(Err(NodeError::ResourceExhausted(message)));
7158                        break (None, Vec::new());
7159                    }
7160                    Err(error) => {
7161                        for index in pending.drain(..) {
7162                            results[index] = Some(Err(error.clone()));
7163                        }
7164                        break (None, Vec::new());
7165                    }
7166                };
7167                if preparation.results.len() != attempted.len() {
7168                    let error = self.latch(NodeError::Invariant(
7169                        "SQLite batch preparation returned a misaligned result vector".into(),
7170                    ));
7171                    for index in pending.drain(..) {
7172                        results[index] = Some(Err(error.clone()));
7173                    }
7174                    break (None, Vec::new());
7175                }
7176
7177                let mut proposed = Vec::new();
7178                for (index, member_result) in attempted.iter().copied().zip(preparation.results) {
7179                    match member_result {
7180                        Ok(result) => proposed.push((index, result)),
7181                        Err(error) => {
7182                            results[index] = Some(Err(self.map_sql_batch_member_error(error)))
7183                        }
7184                    }
7185                }
7186                match preparation.effect {
7187                    Some(_) if proposed.is_empty() => {
7188                        let error = self.latch(NodeError::Invariant(
7189                            "SQLite prepared an effect without a successful SQL member".into(),
7190                        ));
7191                        for index in pending.drain(..) {
7192                            results[index] = Some(Err(error.clone()));
7193                        }
7194                        break (None, Vec::new());
7195                    }
7196                    Some(payload) if !payload.starts_with(QWAL_V3_MAGIC) => {
7197                        let error = self.latch(NodeError::Invariant(
7198                            "SQLite materializer prepared a non-QWAL v3 SQL batch".into(),
7199                        ));
7200                        for index in pending.drain(..) {
7201                            results[index] = Some(Err(error.clone()));
7202                        }
7203                        break (None, Vec::new());
7204                    }
7205                    Some(payload) if payload.len() <= MAX_COMMAND_BYTES => {
7206                        break (Some(payload), proposed)
7207                    }
7208                    Some(_) if attempt_count > 1 => {
7209                        for index in attempted {
7210                            results[*index] = None;
7211                        }
7212                        attempt_count = attempt_count.div_ceil(2);
7213                    }
7214                    Some(_) => {
7215                        results[attempted[0]] = Some(Err(NodeError::ResourceExhausted(format!(
7216                            "SQL effect exceeds {MAX_COMMAND_BYTES} bytes"
7217                        ))));
7218                        break (None, Vec::new());
7219                    }
7220                    None if proposed.is_empty() => break (None, Vec::new()),
7221                    None => {
7222                        let error = self.latch(NodeError::Invariant(
7223                            "SQLite omitted the effect for successful SQL members".into(),
7224                        ));
7225                        for index in pending.drain(..) {
7226                            results[index] = Some(Err(error.clone()));
7227                        }
7228                        break (None, Vec::new());
7229                    }
7230                }
7231            };
7232
7233            pending.retain(|index| results[*index].is_none());
7234            let Some(proposal_payload) = proposal_payload else {
7235                continue;
7236            };
7237            let slot = match last_index.checked_add(1) {
7238                Some(slot) => slot,
7239                None => {
7240                    let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7241                    for index in pending.drain(..) {
7242                        results[index] = Some(Err(error.clone()));
7243                    }
7244                    break;
7245                }
7246            };
7247            let consensus_mark = profile.mark();
7248            let entry = self.consensus.propose_at_cancellable(
7249                slot,
7250                last_hash,
7251                Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7252                &self.operation_cancelled,
7253            );
7254            profile.add_consensus_propose(consensus_mark);
7255            let entry = match entry {
7256                Ok(entry) => entry,
7257                Err(error) => {
7258                    let error = self.map_consensus_error(error);
7259                    for index in pending.drain(..) {
7260                        results[index] = Some(Err(error.clone()));
7261                    }
7262                    break;
7263                }
7264            };
7265            if let Err(error) = self.persist_sql_entry_profiled(&entry, slot, last_hash, profile) {
7266                for index in pending.drain(..) {
7267                    results[index] = Some(Err(error.clone()));
7268                }
7269                break;
7270            }
7271
7272            let exact_winner =
7273                entry.entry_type == EntryType::Command && entry.payload == proposal_payload;
7274            let exact_winner_member_count = exact_winner.then_some(prepared_results.len());
7275            if exact_winner {
7276                for (index, sql_result) in prepared_results {
7277                    results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7278                        WriteResponse {
7279                            applied_index: entry.index,
7280                            hash: entry.hash,
7281                        },
7282                        Some(sql_result),
7283                    ))));
7284                }
7285                pending.retain(|index| results[*index].is_none());
7286            }
7287
7288            let current_pending = std::mem::take(&mut pending);
7289            let classification_mark = profile.mark();
7290            let post_commit = self.check_sql_members_bulk(members, &current_pending);
7291            let post_commit = match post_commit {
7292                Ok(post_commit) => post_commit,
7293                Err(error) => {
7294                    for index in current_pending {
7295                        results[index] = Some(Err(error.clone()));
7296                    }
7297                    profile.add_precheck_classification(classification_mark);
7298                    if let Some(batch_member_count) = exact_winner_member_count {
7299                        profile.record_success(batch_member_count);
7300                    }
7301                    break;
7302                }
7303            };
7304            for (index, lookup) in post_commit {
7305                match lookup {
7306                    Ok(Some((outcome, sql_result))) => {
7307                        results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7308                            write_response(outcome),
7309                            Some(sql_result),
7310                        ))));
7311                    }
7312                    Ok(None) => pending.push(index),
7313                    Err(error) => results[index] = Some(Err(error)),
7314                }
7315            }
7316            profile.add_precheck_classification(classification_mark);
7317            if let Some(batch_member_count) = exact_winner_member_count {
7318                profile.record_success(batch_member_count);
7319            }
7320        }
7321
7322        for (index, canonical) in aliases.into_iter().enumerate() {
7323            if let Some(canonical) = canonical {
7324                results[index] = results[canonical].clone();
7325            }
7326        }
7327        results
7328            .into_iter()
7329            .map(|result| {
7330                result.unwrap_or_else(|| {
7331                    Err(self.latch(NodeError::Invariant(
7332                        "SQL writer batch omitted a request result".into(),
7333                    )))
7334                })
7335            })
7336            .collect()
7337    }
7338
7339    #[cfg(feature = "sql")]
7340    fn check_sql_members_bulk(
7341        &self,
7342        members: &[RuntimeBatchMember],
7343        indices: &[usize],
7344    ) -> Result<Vec<(usize, CheckedSqlRequest)>, NodeError> {
7345        if indices.is_empty() {
7346            return Ok(Vec::new());
7347        }
7348        let mut rounds = Vec::<Vec<usize>>::new();
7349        let mut occurrence_by_request = HashMap::<String, usize>::new();
7350        for index in indices {
7351            let QueuedOperation::Sql(command) = &members[*index].operation else {
7352                return Err(self.latch(NodeError::Invariant(
7353                    "non-SQL member reached SQL receipt precheck".into(),
7354                )));
7355            };
7356            let round = occurrence_by_request
7357                .entry(command.request_id.clone())
7358                .or_default();
7359            if rounds.len() == *round {
7360                rounds.push(Vec::new());
7361            }
7362            rounds[*round].push(*index);
7363            *round += 1;
7364        }
7365
7366        let sqlite = self.lock_sqlite()?;
7367        let mut checked = Vec::with_capacity(indices.len());
7368        for round in rounds {
7369            let requests = round
7370                .iter()
7371                .map(|index| {
7372                    let QueuedOperation::Sql(command) = &members[*index].operation else {
7373                        unreachable!("SQL receipt round contains only SQL members");
7374                    };
7375                    (
7376                        command.request_id.as_str(),
7377                        members[*index].payload.as_slice(),
7378                    )
7379                })
7380                .collect::<Vec<_>>();
7381            let lookups = sqlite
7382                .check_sql_requests(&requests)
7383                .map_err(|error| self.map_sqlite_error(error))?;
7384            if lookups.len() != round.len() {
7385                return Err(self.latch(NodeError::Invariant(
7386                    "SQLite bulk receipt precheck returned a misaligned result vector".into(),
7387                )));
7388            }
7389            for (index, lookup) in round.into_iter().zip(lookups) {
7390                checked.push((
7391                    index,
7392                    match lookup {
7393                        Ok(Some((outcome, Some(sql_result)))) => Ok(Some((outcome, sql_result))),
7394                        Ok(Some((_, None))) => Err(self.latch(NodeError::Invariant(
7395                            "stored SQL receipt omitted its command result".into(),
7396                        ))),
7397                        Ok(None) => Ok(None),
7398                        Err(error) => Err(self.map_sql_batch_member_error(error)),
7399                    },
7400                ));
7401            }
7402        }
7403        Ok(checked)
7404    }
7405
7406    #[cfg(feature = "kv")]
7407    fn execute_kv_group_commit(&self, members: Vec<RuntimeBatchMember>) -> KvGroupCommitResult {
7408        let (job, leader) = self
7409            .kv_group_commit
7410            .enqueue(members, &self.operation_cancelled)?;
7411        if leader {
7412            self.run_kv_group_commit_leader();
7413        }
7414        job.wait(&self.operation_cancelled)
7415    }
7416
7417    #[cfg(feature = "kv")]
7418    fn run_kv_group_commit_leader(&self) {
7419        let _commit = match self.lock_commit() {
7420            Ok(commit) => commit,
7421            Err(error) => {
7422                self.kv_group_commit.fail_pending(error);
7423                return;
7424            }
7425        };
7426
7427        loop {
7428            if !self
7429                .kv_group_commit
7430                .collect_until_full_or_timeout(self.config.writer_batch_window())
7431            {
7432                break;
7433            }
7434            let Some(jobs) = self.kv_group_commit.drain_next_group() else {
7435                break;
7436            };
7437            if let Err(error) = self
7438                .ensure_ready()
7439                .and_then(|_| self.ensure_writes_active())
7440            {
7441                for job in &jobs {
7442                    job.publish(Err(error.clone()));
7443                }
7444                self.kv_group_commit.fail_pending(error);
7445                return;
7446            }
7447            let execution = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7448                #[cfg(test)]
7449                if let Some(hook) = &self.kv_group_commit_before_execute_hook {
7450                    hook();
7451                }
7452                self.execute_kv_group_locked(&jobs)
7453            }));
7454            let grouped_results = match execution {
7455                Ok(Ok(grouped_results)) => grouped_results,
7456                Ok(Err(error)) => {
7457                    let error = if matches!(error, NodeError::Unavailable(_)) {
7458                        error
7459                    } else {
7460                        self.latch(error)
7461                    };
7462                    for job in &jobs {
7463                        job.publish(Err(error.clone()));
7464                    }
7465                    self.kv_group_commit.fail_pending(error);
7466                    return;
7467                }
7468                Err(_) => {
7469                    let error =
7470                        self.latch(NodeError::Fatal("KV group commit leader panicked".into()));
7471                    for job in &jobs {
7472                        job.publish(Err(error.clone()));
7473                    }
7474                    self.kv_group_commit.fail_pending(error);
7475                    return;
7476                }
7477            };
7478            for (job, results) in jobs.iter().zip(grouped_results) {
7479                job.publish(Ok(results));
7480            }
7481        }
7482    }
7483
7484    #[cfg(feature = "kv")]
7485    fn execute_kv_group_locked(
7486        &self,
7487        jobs: &[Arc<KvGroupCommitJob>],
7488    ) -> Result<Vec<Vec<Result<ClientWriteResponse, NodeError>>>, NodeError> {
7489        if self.operation_cancelled.load(Ordering::Acquire) {
7490            return Err(NodeError::Unavailable(
7491                "KV group commit cancelled during shutdown".into(),
7492            ));
7493        }
7494        let total_members = jobs.iter().map(|job| job.member_count).sum();
7495        if total_members == 0 || total_members > MAX_KV_GROUP_COMMIT_MEMBERS {
7496            return Err(NodeError::Invariant(format!(
7497                "KV group commit drained {total_members} members outside 1..={MAX_KV_GROUP_COMMIT_MEMBERS}"
7498            )));
7499        }
7500        let mut members = Vec::with_capacity(total_members);
7501        for job in jobs {
7502            let job_members = job.take_members()?;
7503            if job_members.len() != job.member_count {
7504                return Err(NodeError::Invariant(
7505                    "KV group commit job member count changed while queued".into(),
7506                ));
7507            }
7508            members.extend(job_members);
7509        }
7510        let results = self.execute_kv_client_batch_locked(&members);
7511        #[cfg(test)]
7512        if let Some(hook) = &self.kv_group_commit_after_execute_hook {
7513            hook(self);
7514        }
7515        if results.len() != total_members {
7516            let error = self.latch(NodeError::Invariant(format!(
7517                "KV group commit returned {} results for {total_members} members",
7518                results.len()
7519            )));
7520            return Ok(jobs
7521                .iter()
7522                .map(|job| vec![Err(error.clone()); job.member_count])
7523                .collect());
7524        }
7525        let mut results = results.into_iter();
7526        let grouped = jobs
7527            .iter()
7528            .map(|job| results.by_ref().take(job.member_count).collect::<Vec<_>>())
7529            .collect::<Vec<_>>();
7530        debug_assert!(results.next().is_none());
7531        debug_assert_eq!(grouped.iter().map(Vec::len).sum::<usize>(), total_members);
7532        Ok(grouped)
7533    }
7534
7535    #[cfg(not(feature = "sql"))]
7536    fn execute_client_batch(
7537        &self,
7538        members: Vec<RuntimeBatchMember>,
7539    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7540        self.execute_profile_client_batch(members)
7541    }
7542
7543    fn execute_profile_client_batch(
7544        &self,
7545        members: Vec<RuntimeBatchMember>,
7546    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7547        #[cfg(feature = "graph")]
7548        if self.config.execution_profile == ExecutionProfile::Graph {
7549            return self.execute_graph_client_batch(members);
7550        }
7551        #[cfg(feature = "kv")]
7552        if self.config.execution_profile == ExecutionProfile::Kv {
7553            return self.execute_kv_client_batch(members);
7554        }
7555        let _commit = match self.lock_commit() {
7556            Ok(commit) => commit,
7557            Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7558        };
7559        if let Err(error) = self
7560            .ensure_ready()
7561            .and_then(|_| self.ensure_writes_active())
7562        {
7563            return members.into_iter().map(|_| Err(error.clone())).collect();
7564        }
7565        members
7566            .iter()
7567            .map(|member| self.execute_profile_member_locked(member))
7568            .collect()
7569    }
7570
7571    #[cfg(feature = "graph")]
7572    #[cfg_attr(
7573        all(not(feature = "sql"), not(feature = "kv")),
7574        allow(irrefutable_let_patterns, unreachable_patterns)
7575    )]
7576    fn execute_graph_client_batch(
7577        &self,
7578        members: Vec<RuntimeBatchMember>,
7579    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7580        let _commit = match self.lock_commit() {
7581            Ok(commit) => commit,
7582            Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7583        };
7584        if let Err(error) = self
7585            .ensure_ready()
7586            .and_then(|_| self.ensure_writes_active())
7587        {
7588            return members.into_iter().map(|_| Err(error.clone())).collect();
7589        }
7590
7591        let mut results = vec![None; members.len()];
7592        let mut pending = Vec::new();
7593        let mut canonical_by_request = HashMap::new();
7594        let mut aliases = vec![None; members.len()];
7595        for (index, member) in members.iter().enumerate() {
7596            let QueuedOperation::Graph(command) = &member.operation else {
7597                results[index] = Some(Err(NodeError::ExecutionProfileMismatch {
7598                    expected: ExecutionProfile::Graph,
7599                    actual: ExecutionProfile::Sqlite,
7600                }));
7601                continue;
7602            };
7603            match self.check_graph_request(command.request_id(), &member.payload) {
7604                Ok(Some(record)) => {
7605                    results[index] = Some(Ok(ClientWriteResponse::Graph(
7606                        GraphMutationOutcome::from_record(record),
7607                    )));
7608                }
7609                Ok(None) => match classify_pending_request(
7610                    &mut canonical_by_request,
7611                    &members,
7612                    index,
7613                    command.request_id(),
7614                ) {
7615                    Ok(None) => pending.push(index),
7616                    Ok(Some(canonical)) => aliases[index] = Some(canonical),
7617                    Err(error) => results[index] = Some(Err(error)),
7618                },
7619                Err(error) => results[index] = Some(Err(error)),
7620            }
7621        }
7622
7623        while !pending.is_empty() {
7624            if pending.len() == 1 {
7625                let index = pending[0];
7626                results[index] = Some(self.execute_profile_member_locked(&members[index]));
7627                break;
7628            }
7629            let commands = pending
7630                .iter()
7631                .map(|index| match &members[*index].operation {
7632                    QueuedOperation::Graph(command) => command.clone(),
7633                    _ => unreachable!("graph pending members were validated above"),
7634                })
7635                .collect::<Vec<_>>();
7636            let full_payload = match encode_replicated_graph_batch(&commands) {
7637                Ok(payload) => payload,
7638                Err(error) => {
7639                    let error = NodeError::InvalidRequest(error.to_string());
7640                    for index in pending.drain(..) {
7641                        results[index] = Some(Err(error.clone()));
7642                    }
7643                    break;
7644                }
7645            };
7646            let (proposal_count, proposal_payload) = if full_payload.len() <= MAX_COMMAND_BYTES {
7647                (commands.len(), full_payload)
7648            } else {
7649                let mut prefix = None;
7650                for count in (2..commands.len()).rev() {
7651                    let payload = encode_replicated_graph_batch(&commands[..count])
7652                        .expect("the validated graph batch prefix remains valid");
7653                    if payload.len() <= MAX_COMMAND_BYTES {
7654                        prefix = Some((count, payload));
7655                        break;
7656                    }
7657                }
7658                let Some(prefix) = prefix else {
7659                    let index = pending.remove(0);
7660                    results[index] = Some(self.execute_profile_member_locked(&members[index]));
7661                    continue;
7662                };
7663                prefix
7664            };
7665            let proposed_indices = pending[..proposal_count].to_vec();
7666            let (last_index, last_hash) = match self.ensure_materialized_tip() {
7667                Ok(tip) => tip,
7668                Err(error) => {
7669                    for index in pending.drain(..) {
7670                        results[index] = Some(Err(error.clone()));
7671                    }
7672                    break;
7673                }
7674            };
7675            let slot = match last_index.checked_add(1) {
7676                Some(slot) => slot,
7677                None => {
7678                    let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7679                    for index in pending.drain(..) {
7680                        results[index] = Some(Err(error.clone()));
7681                    }
7682                    break;
7683                }
7684            };
7685            let entry = match self.consensus.propose_at_cancellable(
7686                slot,
7687                last_hash,
7688                Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7689                &self.operation_cancelled,
7690            ) {
7691                Ok(entry) => entry,
7692                Err(error) => {
7693                    let error = self.map_consensus_error(error);
7694                    for index in pending.drain(..) {
7695                        results[index] = Some(Err(error.clone()));
7696                    }
7697                    break;
7698                }
7699            };
7700            if let Err(error) = self.persist_entry(&entry, slot, last_hash) {
7701                for index in pending.drain(..) {
7702                    results[index] = Some(Err(error.clone()));
7703                }
7704                break;
7705            }
7706
7707            let mut remaining = Vec::new();
7708            for index in pending.drain(..) {
7709                let member = &members[index];
7710                let QueuedOperation::Graph(command) = &member.operation else {
7711                    unreachable!("graph pending members were validated above");
7712                };
7713                match self.check_graph_request(command.request_id(), &member.payload) {
7714                    Ok(Some(record)) => {
7715                        results[index] = Some(Ok(ClientWriteResponse::Graph(
7716                            GraphMutationOutcome::from_record(record),
7717                        )));
7718                    }
7719                    Ok(None) => remaining.push(index),
7720                    Err(error) => results[index] = Some(Err(error)),
7721                }
7722            }
7723            if entry.entry_type == EntryType::Command
7724                && entry.payload == proposal_payload
7725                && remaining
7726                    .iter()
7727                    .any(|index| proposed_indices.contains(index))
7728            {
7729                let error = self.latch(NodeError::Invariant(
7730                    "committed graph batch did not record every request".into(),
7731                ));
7732                for index in remaining.drain(..) {
7733                    results[index] = Some(Err(error.clone()));
7734                }
7735            }
7736            pending = remaining;
7737        }
7738
7739        for (index, canonical) in aliases.into_iter().enumerate() {
7740            if let Some(canonical) = canonical {
7741                results[index] = results[canonical].clone();
7742            }
7743        }
7744
7745        results
7746            .into_iter()
7747            .map(|result| {
7748                result.unwrap_or_else(|| {
7749                    Err(self.latch(NodeError::Invariant(
7750                        "graph writer batch omitted a request result".into(),
7751                    )))
7752                })
7753            })
7754            .collect()
7755    }
7756
7757    #[cfg(feature = "kv")]
7758    #[cfg_attr(
7759        all(not(feature = "sql"), not(feature = "graph")),
7760        allow(irrefutable_let_patterns, unreachable_patterns)
7761    )]
7762    fn execute_kv_client_batch(
7763        &self,
7764        members: Vec<RuntimeBatchMember>,
7765    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7766        let _commit = match self.lock_commit() {
7767            Ok(commit) => commit,
7768            Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7769        };
7770        if let Err(error) = self
7771            .ensure_ready()
7772            .and_then(|_| self.ensure_writes_active())
7773        {
7774            return members.into_iter().map(|_| Err(error.clone())).collect();
7775        }
7776        self.execute_kv_client_batch_locked(&members)
7777    }
7778
7779    #[cfg(feature = "kv")]
7780    #[cfg_attr(
7781        all(not(feature = "sql"), not(feature = "graph")),
7782        allow(irrefutable_let_patterns, unreachable_patterns)
7783    )]
7784    fn execute_kv_client_batch_locked(
7785        &self,
7786        members: &[RuntimeBatchMember],
7787    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7788        let mut results = vec![None; members.len()];
7789        let mut pending = Vec::new();
7790        let mut canonical_by_request = HashMap::new();
7791        let mut aliases = vec![None; members.len()];
7792        let mut lookup_indices = Vec::with_capacity(members.len());
7793        for (index, member) in members.iter().enumerate() {
7794            let QueuedOperation::Kv(command) = &member.operation else {
7795                results[index] = Some(Err(NodeError::ExecutionProfileMismatch {
7796                    expected: ExecutionProfile::Kv,
7797                    actual: ExecutionProfile::Sqlite,
7798                }));
7799                continue;
7800            };
7801            match classify_pending_request(
7802                &mut canonical_by_request,
7803                members,
7804                index,
7805                command.request_id(),
7806            ) {
7807                Ok(None) => lookup_indices.push(index),
7808                Ok(Some(canonical)) => aliases[index] = Some(canonical),
7809                Err(error) => results[index] = Some(Err(error)),
7810            }
7811        }
7812        let preflight = match self.check_kv_members_bulk(members, &lookup_indices) {
7813            Ok(preflight) => preflight,
7814            Err(error) => return members.iter().map(|_| Err(error.clone())).collect(),
7815        };
7816        for (index, lookup) in preflight {
7817            match lookup {
7818                Ok(Some(record)) => {
7819                    results[index] = Some(Ok(ClientWriteResponse::Kv(
7820                        KvMutationOutcome::from_record(record),
7821                    )));
7822                }
7823                Ok(None) => pending.push(index),
7824                Err(error) => results[index] = Some(Err(error)),
7825            }
7826        }
7827
7828        while !pending.is_empty() {
7829            if pending.len() == 1 {
7830                let index = pending[0];
7831                results[index] = Some(self.execute_profile_member_locked(&members[index]));
7832                break;
7833            }
7834            let commands = pending
7835                .iter()
7836                .map(|index| match &members[*index].operation {
7837                    QueuedOperation::Kv(command) => command.clone(),
7838                    _ => unreachable!("KV pending members were validated above"),
7839                })
7840                .collect::<Vec<_>>();
7841            let full_payload = match encode_replicated_kv_batch(&commands) {
7842                Ok(payload) => payload,
7843                Err(error) => {
7844                    let error = NodeError::InvalidRequest(error.to_string());
7845                    for index in pending.drain(..) {
7846                        results[index] = Some(Err(error.clone()));
7847                    }
7848                    break;
7849                }
7850            };
7851            let (proposal_count, proposal_payload) = if full_payload.len() <= MAX_COMMAND_BYTES {
7852                (commands.len(), full_payload)
7853            } else {
7854                let Some(prefix) = largest_fitting_kv_batch_prefix(&commands) else {
7855                    let index = pending.remove(0);
7856                    results[index] = Some(self.execute_profile_member_locked(&members[index]));
7857                    continue;
7858                };
7859                prefix
7860            };
7861            let proposed_indices = pending[..proposal_count].to_vec();
7862            let (last_index, last_hash) = match self.ensure_materialized_tip() {
7863                Ok(tip) => tip,
7864                Err(error) => {
7865                    for index in pending.drain(..) {
7866                        results[index] = Some(Err(error.clone()));
7867                    }
7868                    break;
7869                }
7870            };
7871            let slot = match last_index.checked_add(1) {
7872                Some(slot) => slot,
7873                None => {
7874                    let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7875                    for index in pending.drain(..) {
7876                        results[index] = Some(Err(error.clone()));
7877                    }
7878                    break;
7879                }
7880            };
7881            let entry = match self.consensus.propose_at_cancellable(
7882                slot,
7883                last_hash,
7884                Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7885                &self.operation_cancelled,
7886            ) {
7887                Ok(entry) => entry,
7888                Err(error) => {
7889                    let error = self.map_consensus_error(error);
7890                    for index in pending.drain(..) {
7891                        results[index] = Some(Err(error.clone()));
7892                    }
7893                    break;
7894                }
7895            };
7896            if let Err(error) = self.persist_entry(&entry, slot, last_hash) {
7897                for index in pending.drain(..) {
7898                    results[index] = Some(Err(error.clone()));
7899                }
7900                break;
7901            }
7902
7903            let current_pending = std::mem::take(&mut pending);
7904            let post_commit = match self.check_kv_members_bulk(members, &current_pending) {
7905                Ok(post_commit) => post_commit,
7906                Err(error) => {
7907                    for index in current_pending {
7908                        results[index] = Some(Err(error.clone()));
7909                    }
7910                    break;
7911                }
7912            };
7913            let mut remaining = Vec::new();
7914            for (index, lookup) in post_commit {
7915                match lookup {
7916                    Ok(Some(record)) => {
7917                        results[index] = Some(Ok(ClientWriteResponse::Kv(
7918                            KvMutationOutcome::from_record(record),
7919                        )));
7920                    }
7921                    Ok(None) => remaining.push(index),
7922                    Err(error) => results[index] = Some(Err(error)),
7923                }
7924            }
7925            if entry.entry_type == EntryType::Command
7926                && entry.payload == proposal_payload
7927                && remaining
7928                    .iter()
7929                    .any(|index| proposed_indices.contains(index))
7930            {
7931                let error = self.latch(NodeError::Invariant(
7932                    "committed KV batch did not record every request".into(),
7933                ));
7934                for index in remaining.drain(..) {
7935                    results[index] = Some(Err(error.clone()));
7936                }
7937            }
7938            pending = remaining;
7939        }
7940
7941        for (index, canonical) in aliases.into_iter().enumerate() {
7942            if let Some(canonical) = canonical {
7943                results[index] = results[canonical].clone();
7944            }
7945        }
7946
7947        results
7948            .into_iter()
7949            .map(|result| {
7950                result.unwrap_or_else(|| {
7951                    Err(self.latch(NodeError::Invariant(
7952                        "KV writer batch omitted a request result".into(),
7953                    )))
7954                })
7955            })
7956            .collect()
7957    }
7958
7959    #[cfg(feature = "kv")]
7960    #[allow(clippy::infallible_destructuring_match)]
7961    fn check_kv_members_bulk(
7962        &self,
7963        members: &[RuntimeBatchMember],
7964        indices: &[usize],
7965    ) -> Result<Vec<KvMemberCheck>, NodeError> {
7966        if indices.is_empty() {
7967            return Ok(Vec::new());
7968        }
7969        let materializer = self.lock_materializer()?;
7970        let kv = match &*materializer {
7971            Materializer::Kv(kv) => kv,
7972            #[cfg(feature = "sql")]
7973            Materializer::Sql(_) => {
7974                return Err(NodeError::ExecutionProfileMismatch {
7975                    expected: ExecutionProfile::Kv,
7976                    actual: ExecutionProfile::Sqlite,
7977                });
7978            }
7979            #[cfg(feature = "graph")]
7980            Materializer::Graph(_) => {
7981                return Err(NodeError::ExecutionProfileMismatch {
7982                    expected: ExecutionProfile::Kv,
7983                    actual: ExecutionProfile::Graph,
7984                });
7985            }
7986        };
7987        let requests = indices
7988            .iter()
7989            .map(|index| {
7990                let command = match &members[*index].operation {
7991                    QueuedOperation::Kv(command) => command,
7992                    #[cfg(feature = "sql")]
7993                    QueuedOperation::KeyValue { .. } | QueuedOperation::Sql(_) => {
7994                        unreachable!("KV receipt lookup contains only KV members")
7995                    }
7996                    #[cfg(feature = "graph")]
7997                    QueuedOperation::Graph(_) => {
7998                        unreachable!("KV receipt lookup contains only KV members")
7999                    }
8000                };
8001                (command.request_id(), members[*index].payload.as_slice())
8002            })
8003            .collect::<Vec<_>>();
8004        let lookups = kv
8005            .check_requests(&requests)
8006            .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
8007        if lookups.len() != indices.len() {
8008            return Err(self.latch(NodeError::Invariant(
8009                "KV bulk receipt lookup returned a misaligned result vector".into(),
8010            )));
8011        }
8012        Ok(indices
8013            .iter()
8014            .copied()
8015            .zip(
8016                lookups.into_iter().map(|lookup| {
8017                    lookup.map_err(|error| NodeError::InvalidRequest(error.to_string()))
8018                }),
8019            )
8020            .collect())
8021    }
8022
8023    fn execute_profile_member_locked(
8024        &self,
8025        member: &RuntimeBatchMember,
8026    ) -> Result<ClientWriteResponse, NodeError> {
8027        match &member.operation {
8028            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
8029            QueuedOperation::Unavailable => unreachable!("no execution profiles are compiled in"),
8030            #[cfg(feature = "graph")]
8031            QueuedOperation::Graph(command) => self
8032                .mutate_graph_payload_locked(command, member.payload.clone())
8033                .map(ClientWriteResponse::Graph),
8034            #[cfg(feature = "kv")]
8035            QueuedOperation::Kv(command) => self
8036                .mutate_kv_payload_locked(command, member.payload.clone())
8037                .map(ClientWriteResponse::Kv),
8038            #[cfg(feature = "sql")]
8039            QueuedOperation::KeyValue { .. } | QueuedOperation::Sql(_) => {
8040                Err(NodeError::ExecutionProfileMismatch {
8041                    expected: self.config.execution_profile,
8042                    actual: ExecutionProfile::Sqlite,
8043                })
8044            }
8045        }
8046    }
8047
8048    #[cfg(feature = "sql")]
8049    fn execute_single_member_locked(
8050        &self,
8051        member: &RuntimeBatchMember,
8052    ) -> Result<ClientWriteResponse, NodeError> {
8053        if let QueuedOperation::Sql(command) = &member.operation {
8054            self.execute_sql_payload_locked(command, member.payload.clone())
8055                .map(|outcome| {
8056                    ClientWriteResponse::Sql(sql_execute_response(
8057                        outcome.response,
8058                        outcome.sql_result,
8059                    ))
8060                })
8061        } else if let QueuedOperation::KeyValue { key, value } = &member.operation {
8062            self.execute_put_payload_locked(&member.request_id, key, value, member.payload.clone())
8063                .map(|outcome| ClientWriteResponse::KeyValue(outcome.response))
8064        } else {
8065            Err(NodeError::ExecutionProfileMismatch {
8066                expected: ExecutionProfile::Sqlite,
8067                actual: self.config.execution_profile,
8068            })
8069        }
8070    }
8071
8072    #[cfg(feature = "sql")]
8073    fn execute_sql_payload_locked(
8074        &self,
8075        command: &SqlCommand,
8076        request_payload: Vec<u8>,
8077    ) -> Result<ExecutedPayload, NodeError> {
8078        self.ensure_ready()?;
8079        self.ensure_writes_active()?;
8080        if let Some(outcome) = self.check_request(&command.request_id, &request_payload)? {
8081            return Ok(ExecutedPayload {
8082                response: write_response(outcome),
8083                sql_result: self.replay_sql_result(
8084                    &command.request_id,
8085                    &request_payload,
8086                    outcome,
8087                )?,
8088            });
8089        }
8090
8091        loop {
8092            let (last_index, last_hash) = self.ensure_materialized_tip()?;
8093            let proposal_payload =
8094                self.prepare_sql_proposal(command, &request_payload, last_index, last_hash)?;
8095            let slot = last_index.checked_add(1).ok_or_else(|| {
8096                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
8097            })?;
8098            let entry = self
8099                .consensus
8100                .propose_at_cancellable(
8101                    slot,
8102                    last_hash,
8103                    Command::new(CommandKind::Deterministic, proposal_payload.clone()),
8104                    &self.operation_cancelled,
8105                )
8106                .map_err(|error| self.map_consensus_error(error))?;
8107            let sql_result = self.persist_entry(&entry, slot, last_hash)?;
8108            if let Some(outcome) = self.check_request(&command.request_id, &request_payload)? {
8109                return Ok(ExecutedPayload {
8110                    response: write_response(outcome),
8111                    sql_result: sql_result.or(self.replay_sql_result(
8112                        &command.request_id,
8113                        &request_payload,
8114                        outcome,
8115                    )?),
8116                });
8117            }
8118            if entry.entry_type == EntryType::Command && entry.payload == proposal_payload {
8119                return Err(self.latch(NodeError::Invariant(
8120                    "committed SQL request was not recorded by SQLite".into(),
8121                )));
8122            }
8123        }
8124    }
8125
8126    #[cfg(feature = "sql")]
8127    fn prepare_sql_proposal(
8128        &self,
8129        command: &SqlCommand,
8130        request_payload: &[u8],
8131        base_index: LogIndex,
8132        base_hash: LogHash,
8133    ) -> Result<Vec<u8>, NodeError> {
8134        let sqlite = self.lock_sqlite()?;
8135        let preparation = sqlite.prepare_sql_batch_effect(
8136            &[SqlBatchMember {
8137                command,
8138                request_payload,
8139            }],
8140            base_index,
8141            base_hash,
8142        );
8143        let preparation = match preparation {
8144            Ok(preparation) => preparation,
8145            Err(rhiza_sql::Error::ResourceExhausted(message)) => {
8146                return Err(NodeError::ResourceExhausted(message));
8147            }
8148            Err(error) => return Err(self.map_sqlite_error(error)),
8149        };
8150        let result = preparation
8151            .results
8152            .into_iter()
8153            .next()
8154            .expect("one-member SQL preparation returns one result");
8155        if let Err(error) = result {
8156            if let rhiza_sql::Error::ResourceExhausted(message) = error {
8157                return Err(NodeError::ResourceExhausted(message));
8158            }
8159            if let rhiza_sql::Error::RequestConflict(conflict) = error {
8160                return Err(NodeError::RequestConflict(conflict));
8161            }
8162            let message = error.to_string();
8163            let statement_index = first_invalid_sql_statement(command, |prefix| {
8164                let Ok(prefix_payload) = encode_sql_command(prefix) else {
8165                    return true;
8166                };
8167                let prefix_member = [SqlBatchMember {
8168                    command: prefix,
8169                    request_payload: &prefix_payload,
8170                }];
8171                match sqlite.prepare_sql_batch_effect(&prefix_member, base_index, base_hash) {
8172                    Ok(preparation) => preparation
8173                        .results
8174                        .into_iter()
8175                        .next()
8176                        .is_none_or(|result| result.is_err()),
8177                    Err(_) => true,
8178                }
8179            });
8180            return match statement_index {
8181                Some(statement_index) => Err(NodeError::InvalidSqlStatement {
8182                    statement_index,
8183                    message,
8184                }),
8185                None => Err(NodeError::InvalidRequest(message)),
8186            };
8187        }
8188        let payload = preparation.effect.ok_or_else(|| {
8189            self.latch(NodeError::Invariant(
8190                "successful SQL preparation omitted its QWAL v3 effect".into(),
8191            ))
8192        })?;
8193        if !payload.starts_with(QWAL_V3_MAGIC) {
8194            return Err(self.latch(NodeError::Invariant(
8195                "SQLite materializer prepared a non-QWAL v3 SQL proposal".into(),
8196            )));
8197        }
8198        if payload.len() > MAX_COMMAND_BYTES {
8199            return Err(NodeError::ResourceExhausted(format!(
8200                "SQL effect exceeds {MAX_COMMAND_BYTES} bytes"
8201            )));
8202        }
8203        Ok(payload)
8204    }
8205
8206    #[cfg(feature = "sql")]
8207    fn execute_put_payload_locked(
8208        &self,
8209        request_id: &str,
8210        key: &str,
8211        value: &str,
8212        payload: Vec<u8>,
8213    ) -> Result<ExecutedPayload, NodeError> {
8214        self.ensure_ready()?;
8215        self.ensure_writes_active()?;
8216
8217        if let Some(outcome) = self.check_request(request_id, &payload)? {
8218            return Ok(ExecutedPayload {
8219                response: write_response(outcome),
8220                sql_result: None,
8221            });
8222        }
8223
8224        loop {
8225            let (last_index, last_hash) = self.ensure_materialized_tip()?;
8226            let proposal_payload = self
8227                .lock_sqlite()?
8228                .prepare_put_effect(request_id, key, value, &payload, last_index, last_hash)
8229                .map_err(|error| self.map_sqlite_error(error))?;
8230            if !proposal_payload.starts_with(QWAL_V3_MAGIC) {
8231                return Err(self.latch(NodeError::Invariant(
8232                    "SQLite materializer prepared a non-QWAL v3 key/value write proposal".into(),
8233                )));
8234            }
8235            if proposal_payload.len() > MAX_COMMAND_BYTES {
8236                return Err(NodeError::ResourceExhausted(format!(
8237                    "SQLite QWAL effect exceeds {MAX_COMMAND_BYTES} bytes"
8238                )));
8239            }
8240            let slot = last_index.checked_add(1).ok_or_else(|| {
8241                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
8242            })?;
8243            let entry = self
8244                .consensus
8245                .propose_at_cancellable(
8246                    slot,
8247                    last_hash,
8248                    Command::new(CommandKind::Deterministic, proposal_payload.clone()),
8249                    &self.operation_cancelled,
8250                )
8251                .map_err(|error| self.map_consensus_error(error))?;
8252            self.persist_entry(&entry, slot, last_hash)?;
8253
8254            if let Some(outcome) = self.check_request(request_id, &payload)? {
8255                return Ok(ExecutedPayload {
8256                    response: write_response(outcome),
8257                    sql_result: None,
8258                });
8259            }
8260            if entry.entry_type == EntryType::Command && entry.payload == proposal_payload {
8261                return Err(self.latch(NodeError::Invariant(
8262                    "committed key/value write request was not recorded by SQLite QWAL".into(),
8263                )));
8264            }
8265        }
8266    }
8267
8268    #[cfg(feature = "sql")]
8269    fn replay_sql_result(
8270        &self,
8271        request_id: &str,
8272        payload: &[u8],
8273        outcome: RequestOutcome,
8274    ) -> Result<Option<SqlCommandResult>, NodeError> {
8275        let sqlite = self.lock_sqlite()?;
8276        let stored = sqlite
8277            .check_sql_request(request_id, payload)
8278            .map_err(|error| self.map_sqlite_error(error))?
8279            .ok_or_else(|| {
8280                self.latch(NodeError::Invariant(
8281                    "committed SQL request result is missing".into(),
8282                ))
8283            })?;
8284        if stored.0 != outcome {
8285            return Err(self.latch(NodeError::Invariant(
8286                "stored SQL request outcome changed".into(),
8287            )));
8288        }
8289        Ok(stored.1)
8290    }
8291
8292    #[cfg(feature = "sql")]
8293    fn map_sql_batch_member_error(&self, error: rhiza_sql::Error) -> NodeError {
8294        match error {
8295            rhiza_sql::Error::RequestConflict(conflict) => NodeError::RequestConflict(conflict),
8296            rhiza_sql::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
8297            rhiza_sql::Error::InvalidCommand(message) | rhiza_sql::Error::Sqlite(message) => {
8298                NodeError::InvalidSqlStatement {
8299                    statement_index: 0,
8300                    message,
8301                }
8302            }
8303            other => self.map_sqlite_error(other),
8304        }
8305    }
8306
8307    #[cfg(feature = "sql")]
8308    pub fn read(&self, key: &str, consistency: ReadConsistency) -> Result<ReadResponse, NodeError> {
8309        validate_key(key)?;
8310        match consistency {
8311            ReadConsistency::Local => self.read_local(key, None),
8312            ReadConsistency::AppliedIndex(required) => self.read_local(key, Some(required)),
8313            ReadConsistency::ReadBarrier => {
8314                let anchor = self.establish_read_barrier()?;
8315                let _commit = self.lock_commit()?;
8316                self.ensure_ready()?;
8317                self.ensure_writes_active()?;
8318                self.validate_read_barrier_descendant_locked(anchor)?;
8319                self.read_local(key, Some(anchor.index()))
8320            }
8321        }
8322    }
8323
8324    #[cfg(feature = "sql")]
8325    pub fn query_sql(
8326        &self,
8327        statement: &SqlStatement,
8328        consistency: ReadConsistency,
8329        max_rows: u32,
8330    ) -> Result<SqlQueryResponse, NodeError> {
8331        if max_rows == 0 || max_rows > MAX_SQL_MAX_ROWS {
8332            return Err(NodeError::InvalidRequest(format!(
8333                "max_rows must be between 1 and {MAX_SQL_MAX_ROWS}"
8334            )));
8335        }
8336        match consistency {
8337            ReadConsistency::Local => self.query_sql_local(statement, None, max_rows),
8338            ReadConsistency::AppliedIndex(required) => {
8339                self.query_sql_local(statement, Some(required), max_rows)
8340            }
8341            ReadConsistency::ReadBarrier => {
8342                let anchor = self.establish_read_barrier()?;
8343                let _commit = self.lock_commit()?;
8344                self.ensure_ready()?;
8345                self.ensure_writes_active()?;
8346                self.validate_read_barrier_descendant_locked(anchor)?;
8347                self.query_sql_local(statement, Some(anchor.index()), max_rows)
8348            }
8349        }
8350    }
8351
8352    pub fn applied_index(&self) -> Result<LogIndex, NodeError> {
8353        self.ensure_ready()?;
8354        self.lock_materializer()?
8355            .applied_index()
8356            .map_err(|error| self.latch(NodeError::Storage(error)))
8357    }
8358
8359    pub fn applied_hash(&self) -> Result<LogHash, NodeError> {
8360        self.ensure_ready()?;
8361        self.lock_materializer()?
8362            .applied_hash()
8363            .map_err(|error| self.latch(NodeError::Storage(error)))
8364    }
8365
8366    pub fn cancel_operations(&self) {
8367        self.operation_cancelled.store(true, Ordering::Release);
8368        #[cfg(feature = "sql")]
8369        self.sql_group_commit.fail_pending(NodeError::Unavailable(
8370            "SQL group commit cancelled during shutdown".into(),
8371        ));
8372        #[cfg(feature = "kv")]
8373        self.kv_group_commit.fail_pending(NodeError::Unavailable(
8374            "KV group commit cancelled during shutdown".into(),
8375        ));
8376        self.read_barriers.cancel_waiters();
8377        self.operation_cancelled_notify.notify_waiters();
8378    }
8379
8380    pub fn materialize_next_decision(&self) -> Result<bool, NodeError> {
8381        let _commit = self.lock_commit()?;
8382        self.ensure_ready()?;
8383        let (last_index, last_hash) = self.ensure_materialized_tip()?;
8384        let slot = last_index
8385            .checked_add(1)
8386            .ok_or_else(|| self.latch(NodeError::Invariant("qlog index is exhausted".into())))?;
8387        match self
8388            .consensus
8389            .inspect_decision_at(slot, last_hash)
8390            .map_err(|error| self.map_consensus_error(error))?
8391        {
8392            DecisionInspection::Committed(entry) => {
8393                self.persist_entry(&entry, slot, last_hash)?;
8394                Ok(true)
8395            }
8396            DecisionInspection::Empty | DecisionInspection::Pending => Ok(false),
8397            DecisionInspection::Unavailable => Err(NodeError::Unavailable(
8398                "decision proof inspection did not reach quorum".into(),
8399            )),
8400        }
8401    }
8402
8403    pub async fn run_background_materializer<F>(
8404        self: Arc<Self>,
8405        poll_interval: Duration,
8406        shutdown: F,
8407    ) -> Result<(), NodeError>
8408    where
8409        F: std::future::Future<Output = ()> + Send,
8410    {
8411        let poll_interval = poll_interval.max(Duration::from_millis(10));
8412        tokio::pin!(shutdown);
8413        loop {
8414            tokio::select! {
8415                () = &mut shutdown => return Ok(()),
8416                () = tokio::time::sleep(poll_interval) => {
8417                    loop {
8418                        let runtime = Arc::clone(&self);
8419                        let mut operation = tokio::task::spawn_blocking(move || runtime.materialize_next_decision());
8420                        let (result, shutting_down) = tokio::select! {
8421                            () = &mut shutdown => {
8422                                self.cancel_operations();
8423                                (operation.await, true)
8424                            }
8425                            result = &mut operation => (result, false),
8426                        };
8427                        if shutting_down {
8428                            return match result {
8429                                Ok(Ok(_) | Err(NodeError::Unavailable(_) | NodeError::Contention(_))) => Ok(()),
8430                                Ok(Err(error)) => Err(error),
8431                                Err(error) => Err(NodeError::Fatal(format!("materializer task failed: {error}"))),
8432                            };
8433                        }
8434                        match result {
8435                            Ok(Ok(true)) => continue,
8436                            Ok(Ok(false) | Err(NodeError::Unavailable(_) | NodeError::Contention(_))) => break,
8437                            Ok(Err(error)) => return Err(error),
8438                            Err(error) => return Err(NodeError::Fatal(format!("materializer task failed: {error}"))),
8439                        }
8440                    }
8441                }
8442            }
8443        }
8444    }
8445
8446    pub const fn config(&self) -> &NodeConfig {
8447        &self.config
8448    }
8449
8450    pub const fn consensus(&self) -> &Arc<ThreeNodeConsensus> {
8451        &self.consensus
8452    }
8453
8454    pub const fn log_store(&self) -> &FileLogStore {
8455        &self.log_store
8456    }
8457
8458    pub fn configuration_state(&self) -> Result<ConfigurationState, NodeError> {
8459        self.log_store
8460            .configuration_state()
8461            .map_err(|error| NodeError::Storage(error.to_string()))
8462    }
8463
8464    pub fn status(&self) -> Result<NodeStatus, NodeError> {
8465        let configuration_state = self.configuration_state()?;
8466        let (configuration_status, active_config_id) = if configuration_state.is_active() {
8467            (
8468                RuntimeConfigurationStatus::Active,
8469                configuration_state.config_id(),
8470            )
8471        } else if configuration_state.config_id() == self.consensus.config_id() {
8472            (
8473                RuntimeConfigurationStatus::Stopped,
8474                configuration_state.config_id(),
8475            )
8476        } else {
8477            (
8478                RuntimeConfigurationStatus::AwaitingActivation,
8479                configuration_state
8480                    .config_id()
8481                    .checked_add(1)
8482                    .ok_or_else(|| {
8483                        NodeError::Invariant("successor configuration id is exhausted".into())
8484                    })?,
8485            )
8486        };
8487        Ok(NodeStatus {
8488            ready: self.is_ready(),
8489            stop_anchor: configuration_state.stop().copied(),
8490            active_config_id,
8491            active_membership_digest: self.config.membership.digest(),
8492            configuration_status,
8493            configuration_state,
8494        })
8495    }
8496
8497    pub fn stop_current_configuration_for_successor(
8498        &self,
8499        successor: &Membership,
8500    ) -> Result<StopInformation, NodeError> {
8501        let _commit = self.lock_commit()?;
8502        self.stop_current_configuration_locked(successor)
8503    }
8504
8505    fn stop_current_configuration_locked(
8506        &self,
8507        successor: &Membership,
8508    ) -> Result<StopInformation, NodeError> {
8509        self.ensure_ready()?;
8510        self.ensure_writes_active()?;
8511        let state = self.configuration_state()?;
8512        let stop_command = ConfigChange::bound_stop(
8513            self.config.cluster_id.clone(),
8514            state.config_id(),
8515            state.digest(),
8516            state
8517                .config_id()
8518                .checked_add(1)
8519                .ok_or_else(|| NodeError::Invariant("successor config id is exhausted".into()))?,
8520            successor.members().to_vec(),
8521        )
8522        .map_err(|error| NodeError::Invariant(error.to_string()))?
8523        .to_stored_command();
8524        loop {
8525            let (last_index, last_hash) = self.ensure_materialized_tip()?;
8526            let slot = last_index
8527                .checked_add(1)
8528                .ok_or_else(|| NodeError::Invariant("qlog index is exhausted".into()))?;
8529            let entry = self
8530                .consensus
8531                .propose_stored_at(slot, last_hash, stop_command.clone())
8532                .map_err(|error| self.map_consensus_error(error))?;
8533            self.persist_entry(&entry, slot, last_hash)?;
8534            let decided = StoredCommand::new(entry.entry_type, entry.payload.clone());
8535            if decided != stop_command {
8536                let current = self.configuration_state()?;
8537                if current.is_active() {
8538                    continue;
8539                }
8540                return Err(NodeError::ConfigurationTransition {
8541                    state: Box::new(current),
8542                });
8543            }
8544            let proof = self
8545                .consensus
8546                .inspect_decision_proof_at(slot)
8547                .map_err(|error| self.map_consensus_error(error))?
8548                .ok_or_else(|| {
8549                    NodeError::Unavailable("durable Stop proof is unavailable".into())
8550                })?;
8551            if proof
8552                .proposal()
8553                .value
8554                .as_ref()
8555                .map(|value| value.entry_hash)
8556                != Some(entry.hash)
8557            {
8558                return Err(self.latch(NodeError::Reconciliation(
8559                    "Stop proof differs from committed stop entry".into(),
8560                )));
8561            }
8562            return Ok(StopInformation { entry, proof });
8563        }
8564    }
8565
8566    pub fn activate_successor(&self) -> Result<LogEntry, NodeError> {
8567        let _commit = self.lock_commit()?;
8568        self.activate_successor_locked(None)
8569    }
8570
8571    pub fn activate_successor_if(&self, expected_config_id: u64) -> Result<LogEntry, NodeError> {
8572        let _commit = self.lock_commit()?;
8573        self.activate_successor_locked(Some(expected_config_id))
8574    }
8575
8576    fn activate_successor_locked(
8577        &self,
8578        expected_config_id: Option<u64>,
8579    ) -> Result<LogEntry, NodeError> {
8580        self.ensure_ready()?;
8581        let state = self.configuration_state()?;
8582        let stop = state
8583            .stop()
8584            .copied()
8585            .ok_or_else(|| NodeError::ConfigurationTransition {
8586                state: Box::new(state.clone()),
8587            })?;
8588        if state.config_id() == self.consensus.config_id() {
8589            return Err(NodeError::ConfigurationTransition {
8590                state: Box::new(state),
8591            });
8592        }
8593        let successor_config_id = state.config_id().checked_add(1).ok_or_else(|| {
8594            NodeError::Invariant("successor configuration id is exhausted".into())
8595        })?;
8596        if expected_config_id.is_some_and(|expected| expected != successor_config_id) {
8597            return Err(NodeError::PreconditionFailed(
8598                "successor configuration does not match expected_config_id".into(),
8599            ));
8600        }
8601        let stop_entry = self.recover_stop_entry(stop)?;
8602        let entry = self
8603            .consensus
8604            .propose_activation_for_stop_entry(&stop_entry)
8605            .map_err(|error| self.map_consensus_error(error))?;
8606        self.persist_entry(&entry, stop.index() + 1, stop.hash())?;
8607        Ok(entry)
8608    }
8609
8610    pub(crate) fn recover_stop_entry(&self, stop: LogAnchor) -> Result<LogEntry, NodeError> {
8611        if let Some(entry) = self
8612            .log_store
8613            .read(stop.index())
8614            .map_err(|error| NodeError::Storage(error.to_string()))?
8615            .filter(|entry| entry.hash == stop.hash())
8616        {
8617            return Ok(entry);
8618        }
8619        if let Some(entry) = self
8620            .config
8621            .predecessor_stop_entry
8622            .as_ref()
8623            .filter(|entry| entry.index == stop.index() && entry.hash == stop.hash())
8624        {
8625            validate_entry_envelope(&self.config, entry, entry.index, entry.prev_hash)?;
8626            return Ok(entry.clone());
8627        }
8628        let proof = self
8629            .consensus
8630            .inspect_decision_proof_at(stop.index())
8631            .map_err(|error| self.map_consensus_error(error))?
8632            .ok_or_else(|| NodeError::Unavailable("durable Stop proof is unavailable".into()))?;
8633        let value = proof
8634            .proposal()
8635            .value
8636            .as_ref()
8637            .filter(|value| value.entry_hash == stop.hash())
8638            .ok_or_else(|| {
8639                self.latch(NodeError::Reconciliation(
8640                    "Stop proof differs from compacted anchor".into(),
8641                ))
8642            })?;
8643        match self
8644            .consensus
8645            .inspect_decision_at(stop.index(), value.prev_hash)
8646            .map_err(|error| self.map_consensus_error(error))?
8647        {
8648            DecisionInspection::Committed(entry) if entry.hash == stop.hash() => Ok(entry),
8649            DecisionInspection::Unavailable => Err(NodeError::Unavailable(
8650                "durable Stop command is unavailable".into(),
8651            )),
8652            DecisionInspection::Committed(_)
8653            | DecisionInspection::Empty
8654            | DecisionInspection::Pending => Err(self.latch(NodeError::Reconciliation(
8655                "Stop decision differs from compacted anchor".into(),
8656            ))),
8657        }
8658    }
8659
8660    pub fn log_root(&self) -> Result<LogAnchor, NodeError> {
8661        let _commit = self.lock_commit()?;
8662        self.log_root_unlocked()
8663    }
8664
8665    fn log_root_unlocked(&self) -> Result<LogAnchor, NodeError> {
8666        let state = self
8667            .log_store
8668            .logical_state()
8669            .map_err(|error| NodeError::Storage(error.to_string()))?;
8670        Ok(state.tip.map_or_else(
8671            || {
8672                state
8673                    .anchor
8674                    .map_or(LogAnchor::new(0, LogHash::ZERO), |anchor| {
8675                        *anchor.compacted()
8676                    })
8677            },
8678            |entry| LogAnchor::new(entry.index(), entry.hash()),
8679        ))
8680    }
8681
8682    pub fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
8683        fetch_runtime_log(self, request)
8684    }
8685
8686    #[cfg(feature = "sql")]
8687    pub fn create_recovery_snapshot(&self) -> Result<RecoverySnapshot, NodeError> {
8688        let _commit = self.lock_commit()?;
8689        self.ensure_ready()?;
8690        self.ensure_materialized_tip()?;
8691        self.lock_sqlite()?
8692            .create_recovery_snapshot(self.config.recovery_generation)
8693            .map_err(|error| self.map_sqlite_error(error))
8694    }
8695
8696    pub async fn checkpoint_compact(
8697        &self,
8698        coordinator: &CheckpointCoordinator,
8699    ) -> Result<RecoveryAnchor, DurabilityError> {
8700        coordinator.checkpoint_compact(self).await
8701    }
8702
8703    #[cfg_attr(not(any(feature = "sql", feature = "kv")), allow(unused_variables))]
8704    pub(crate) fn compact_embedded_log_before(
8705        &self,
8706        anchor_index: LogIndex,
8707    ) -> Result<(), NodeError> {
8708        let materializer = self.lock_materializer()?;
8709        match &*materializer {
8710            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
8711            Materializer::Unavailable => unreachable!("no execution profiles are compiled in"),
8712            #[cfg(feature = "sql")]
8713            Materializer::Sql(sql) => sql
8714                .compact_embedded_log_before(anchor_index)
8715                .map_err(|error| self.map_sqlite_error(error)),
8716            #[cfg(feature = "kv")]
8717            Materializer::Kv(kv) => kv
8718                .compact_embedded_log_before(anchor_index)
8719                .map_err(|error| NodeError::Storage(error.to_string())),
8720            #[cfg(feature = "graph")]
8721            Materializer::Graph(_) => Ok(()),
8722        }
8723    }
8724
8725    #[cfg(feature = "sql")]
8726    pub fn verify_snapshot_publication(
8727        &self,
8728        snapshot: &RecoverySnapshot,
8729        publication: &SnapshotRecord,
8730    ) -> Result<VerifiedSnapshotPublication, NodeError> {
8731        let anchor = snapshot.anchor();
8732        let manifest = publication.manifest();
8733        let publication_digest = LogHash::from_hex(publication.sha256()).ok_or_else(|| {
8734            NodeError::Reconciliation("published snapshot digest is invalid".into())
8735        })?;
8736        if anchor.cluster_id() != self.config.cluster_id
8737            || anchor.epoch() != self.config.epoch
8738            || anchor.config_id() != self.config.config_id()
8739            || anchor.recovery_generation() != self.config.recovery_generation
8740            || manifest.cluster_id() != anchor.cluster_id()
8741            || manifest.epoch() != anchor.epoch()
8742            || manifest.config_id() != anchor.config_id()
8743            || manifest.configuration_state() != anchor.configuration_state()
8744            || manifest.index() != anchor.compacted().index()
8745            || manifest.applied_hash() != anchor.compacted().hash()
8746            || manifest.snapshot_id() != anchor.snapshot().snapshot_id()
8747            || manifest.executor_fingerprint() != anchor.snapshot().executor_fingerprint()
8748            || publication_digest != anchor.snapshot().digest()
8749            || publication.size_bytes() != anchor.snapshot().size_bytes()
8750            || LogHash::digest(&[snapshot.db_bytes()]) != anchor.snapshot().digest()
8751            || snapshot.db_bytes().len() as u64 != anchor.snapshot().size_bytes()
8752        {
8753            return Err(NodeError::Reconciliation(
8754                "published snapshot does not match the runtime recovery anchor".into(),
8755            ));
8756        }
8757        Ok(VerifiedSnapshotPublication {
8758            anchor: anchor.clone(),
8759        })
8760    }
8761
8762    #[cfg(feature = "sql")]
8763    pub fn compact_log(&self, publication: &VerifiedSnapshotPublication) -> Result<(), NodeError> {
8764        let _commit = self.lock_commit()?;
8765        self.ensure_ready()?;
8766        let applied_index = self.applied_index()?;
8767        let applied_hash = self.applied_hash()?;
8768        let anchor = &publication.anchor;
8769        if anchor.cluster_id() != self.config.cluster_id
8770            || anchor.epoch() != self.config.epoch
8771            || anchor.config_id() != self.config.config_id()
8772            || anchor.recovery_generation() != self.config.recovery_generation
8773            || anchor.compacted().index() != applied_index
8774            || anchor.compacted().hash() != applied_hash
8775        {
8776            return Err(NodeError::Reconciliation(
8777                "verified snapshot anchor does not match the current applied entry".into(),
8778            ));
8779        }
8780        self.log_store
8781            .compact_prefix(anchor)
8782            .map_err(|error| NodeError::Storage(error.to_string()))?;
8783        self.compact_embedded_log_before(anchor.compacted().index())
8784    }
8785
8786    pub fn is_ready(&self) -> bool {
8787        self.ready.load(Ordering::Acquire) && !self.fatal.load(Ordering::Acquire)
8788    }
8789
8790    pub fn is_fatal(&self) -> bool {
8791        self.fatal.load(Ordering::Acquire)
8792    }
8793
8794    pub fn fatal_reason(&self) -> Option<String> {
8795        self.fatal_reason
8796            .lock()
8797            .ok()
8798            .and_then(|reason| reason.clone())
8799    }
8800
8801    #[cfg(feature = "sql")]
8802    fn read_local(
8803        &self,
8804        key: &str,
8805        required_index: Option<LogIndex>,
8806    ) -> Result<ReadResponse, NodeError> {
8807        self.ensure_ready()?;
8808        self.ensure_writes_active()?;
8809        let sqlite = self.lock_sqlite()?;
8810        let (applied_index, hash) = sqlite
8811            .applied_tip_value()
8812            .map_err(|error| self.map_sqlite_error(error))?;
8813        if required_index.is_some_and(|required| applied_index < required) {
8814            return Err(NodeError::Unavailable(format!(
8815                "local applied index {applied_index} has not reached {}",
8816                required_index.expect("checked above")
8817            )));
8818        }
8819        let value = sqlite
8820            .get_value(key)
8821            .map_err(|error| self.map_sqlite_error(error))?;
8822        Ok(ReadResponse {
8823            value,
8824            applied_index,
8825            hash,
8826        })
8827    }
8828
8829    #[cfg(feature = "graph")]
8830    fn get_graph_document_local(
8831        &self,
8832        id: &str,
8833        required_index: Option<LogIndex>,
8834    ) -> Result<GraphReadResponse, NodeError> {
8835        self.ensure_ready()?;
8836        self.ensure_writes_active()?;
8837        let graph = self.graph_materializer()?;
8838        let (value, applied_index, hash) = graph
8839            .get_document_with_tip(id)
8840            .map_err(|error| self.map_graph_read_error(error))?;
8841        if required_index.is_some_and(|required| applied_index < required) {
8842            return Err(NodeError::Unavailable(format!(
8843                "local applied index {applied_index} has not reached {}",
8844                required_index.expect("checked above")
8845            )));
8846        }
8847        Ok(GraphReadResponse {
8848            value,
8849            applied_index,
8850            hash,
8851        })
8852    }
8853
8854    #[cfg(feature = "graph")]
8855    fn query_graph_local(
8856        &self,
8857        statement: &str,
8858        parameters: &BTreeMap<String, GraphParameterValue>,
8859        required_index: Option<LogIndex>,
8860        max_rows: u32,
8861    ) -> Result<GraphQueryResult, NodeError> {
8862        self.ensure_ready()?;
8863        self.ensure_writes_active()?;
8864        let graph = self.graph_materializer()?;
8865        let result = graph
8866            .query_read_only(
8867                statement,
8868                parameters,
8869                usize::try_from(max_rows).expect("u32 fits usize"),
8870                MAX_GRAPH_RESULT_BYTES,
8871                GRAPH_QUERY_TIMEOUT_MS,
8872            )
8873            .map_err(|error| self.map_graph_read_error(error))?;
8874        if required_index.is_some_and(|required| result.applied_index < required) {
8875            return Err(NodeError::Unavailable(format!(
8876                "local applied index {} has not reached {}",
8877                result.applied_index,
8878                required_index.expect("checked above")
8879            )));
8880        }
8881        Ok(result)
8882    }
8883
8884    #[cfg(feature = "kv")]
8885    fn get_kv_local(
8886        &self,
8887        key: &[u8],
8888        required_index: Option<LogIndex>,
8889    ) -> Result<KvReadResponse, NodeError> {
8890        self.ensure_ready()?;
8891        self.ensure_writes_active()?;
8892        let kv = self.kv_materializer()?;
8893        let result = kv
8894            .get_with_tip(key)
8895            .map_err(|error| self.map_kv_read_error(error))?;
8896        let (value, tip) = result.into_parts();
8897        let applied_index = tip.applied_index();
8898        if required_index.is_some_and(|required| applied_index < required) {
8899            return Err(NodeError::Unavailable(format!(
8900                "local applied index {applied_index} has not reached {}",
8901                required_index.expect("checked above")
8902            )));
8903        }
8904        Ok(KvReadResponse {
8905            value,
8906            applied_index,
8907            hash: tip.applied_hash(),
8908        })
8909    }
8910
8911    #[cfg(feature = "kv")]
8912    fn scan_kv_range_local(
8913        &self,
8914        start: &[u8],
8915        end: Option<&[u8]>,
8916        limit: usize,
8917        cursor: Option<&[u8]>,
8918        required_index: Option<LogIndex>,
8919    ) -> Result<KvScanResult, NodeError> {
8920        self.ensure_ready()?;
8921        self.ensure_writes_active()?;
8922        let kv = self.kv_materializer()?;
8923        let result = kv
8924            .scan_range(start, end, limit, cursor)
8925            .map_err(|error| self.map_kv_read_error(error))?;
8926        validate_kv_scan_required_index(&result, required_index)?;
8927        Ok(result)
8928    }
8929
8930    #[cfg(feature = "kv")]
8931    fn scan_kv_prefix_local(
8932        &self,
8933        prefix: &[u8],
8934        limit: usize,
8935        cursor: Option<&[u8]>,
8936        required_index: Option<LogIndex>,
8937    ) -> Result<KvScanResult, NodeError> {
8938        self.ensure_ready()?;
8939        self.ensure_writes_active()?;
8940        let kv = self.kv_materializer()?;
8941        let result = kv
8942            .scan_prefix(prefix, limit, cursor)
8943            .map_err(|error| self.map_kv_read_error(error))?;
8944        validate_kv_scan_required_index(&result, required_index)?;
8945        Ok(result)
8946    }
8947
8948    #[cfg(feature = "sql")]
8949    fn query_sql_local(
8950        &self,
8951        statement: &SqlStatement,
8952        required_index: Option<LogIndex>,
8953        max_rows: u32,
8954    ) -> Result<SqlQueryResponse, NodeError> {
8955        self.ensure_ready()?;
8956        self.ensure_writes_active()?;
8957        let sqlite = self.lock_sqlite()?;
8958        let (applied_index, hash) = sqlite
8959            .applied_tip_value()
8960            .map_err(|error| self.map_sqlite_error(error))?;
8961        if required_index.is_some_and(|required| applied_index < required) {
8962            return Err(NodeError::Unavailable(format!(
8963                "local applied index {applied_index} has not reached {}",
8964                required_index.expect("checked above")
8965            )));
8966        }
8967        let SqlQueryResult { columns, rows } = sqlite
8968            .query_sql(
8969                statement,
8970                usize::try_from(max_rows).expect("u32 fits usize"),
8971                MAX_SQL_RESULT_BYTES,
8972            )
8973            .map_err(|error| match error {
8974                rhiza_sql::Error::ResourceExhausted(message) => {
8975                    NodeError::ResourceExhausted(message)
8976                }
8977                other => NodeError::InvalidSqlStatement {
8978                    statement_index: 0,
8979                    message: other.to_string(),
8980                },
8981            })?;
8982        Ok(SqlQueryResponse {
8983            columns,
8984            rows,
8985            applied_index,
8986            hash,
8987        })
8988    }
8989
8990    fn establish_read_barrier(&self) -> Result<LogAnchor, NodeError> {
8991        let participant = self.read_barriers.join().map_err(|error| match error {
8992            NodeError::Invariant(_) => self.latch(error),
8993            other => other,
8994        })?;
8995        let Some(mut publication) = participant.publication() else {
8996            return participant.wait(&self.operation_cancelled);
8997        };
8998
8999        let result = (|| {
9000            publication.wait_turn(&self.operation_cancelled)?;
9001            // The public read path must not own this mutex before entering the
9002            // coalescer. The generation cutoff happens only after the round
9003            // leader owns commit, immediately before consensus begins.
9004            let _commit = self.lock_commit()?;
9005            publication.start(&self.operation_cancelled)?;
9006            self.ensure_ready()?;
9007            self.commit_read_barrier_locked()
9008        })();
9009        publication.publish(result.clone());
9010        result
9011    }
9012
9013    #[cfg(any(feature = "graph", feature = "kv"))]
9014    fn validate_read_barrier_before_snapshot(&self, anchor: LogAnchor) -> Result<(), NodeError> {
9015        {
9016            let _commit = self.lock_commit()?;
9017            self.ensure_ready()?;
9018            self.ensure_writes_active()?;
9019            self.validate_read_barrier_qlog_descendant_locked(anchor)?;
9020        }
9021        Ok(())
9022    }
9023
9024    #[cfg(any(feature = "graph", feature = "kv"))]
9025    fn validate_read_barrier_snapshot(
9026        &self,
9027        anchor: LogAnchor,
9028        observed: LogAnchor,
9029    ) -> Result<(), NodeError> {
9030        if observed.index() < anchor.index() {
9031            return Err(NodeError::Unavailable(format!(
9032                "read snapshot tip {} precedes read barrier {}",
9033                observed.index(),
9034                anchor.index()
9035            )));
9036        }
9037        if observed.index() == anchor.index() && observed.hash() != anchor.hash() {
9038            return Err(self.latch(NodeError::Invariant(
9039                "read snapshot tip hash differs from the read barrier anchor".into(),
9040            )));
9041        }
9042        Ok(())
9043    }
9044
9045    #[cfg(feature = "sql")]
9046    fn validate_read_barrier_descendant_locked(&self, anchor: LogAnchor) -> Result<(), NodeError> {
9047        let (applied_index, applied_hash) = self.ensure_materialized_tip()?;
9048        self.validate_read_barrier_descendant_from_tip(
9049            anchor,
9050            LogAnchor::new(applied_index, applied_hash),
9051            "materialized",
9052        )
9053    }
9054
9055    #[cfg(any(feature = "graph", feature = "kv"))]
9056    fn validate_read_barrier_qlog_descendant_locked(
9057        &self,
9058        anchor: LogAnchor,
9059    ) -> Result<(), NodeError> {
9060        let (qlog_index, qlog_hash) = self.durable_tip()?;
9061        self.validate_read_barrier_descendant_from_tip(
9062            anchor,
9063            LogAnchor::new(qlog_index, qlog_hash),
9064            "qlog",
9065        )
9066    }
9067
9068    fn validate_read_barrier_descendant_from_tip(
9069        &self,
9070        anchor: LogAnchor,
9071        tip: LogAnchor,
9072        tip_kind: &str,
9073    ) -> Result<(), NodeError> {
9074        if tip.index() < anchor.index() {
9075            return Err(self.latch(NodeError::Invariant(format!(
9076                "{tip_kind} tip {} precedes read barrier {}",
9077                tip.index(),
9078                anchor.index()
9079            ))));
9080        }
9081        if tip.index() == anchor.index() {
9082            if tip.hash() != anchor.hash() {
9083                return Err(self.latch(NodeError::Invariant(format!(
9084                    "{tip_kind} tip hash differs from the read barrier anchor"
9085                ))));
9086            }
9087            return Ok(());
9088        }
9089        if anchor.index() == 0 {
9090            if anchor.hash() == LogHash::ZERO {
9091                return Ok(());
9092            }
9093            return Err(self.latch(NodeError::Invariant(
9094                "genesis read barrier anchor has a non-zero hash".into(),
9095            )));
9096        }
9097
9098        let logical = self
9099            .log_store
9100            .logical_state()
9101            .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9102        if let Some(compacted) = logical.anchor.as_ref().map(RecoveryAnchor::compacted) {
9103            if compacted.index() > anchor.index() {
9104                return Ok(());
9105            }
9106            if compacted.index() == anchor.index() {
9107                if compacted.hash() == anchor.hash() {
9108                    return Ok(());
9109                }
9110                return Err(self.latch(NodeError::Invariant(
9111                    "compacted qlog hash differs from the read barrier anchor".into(),
9112                )));
9113            }
9114        }
9115        let retained = self
9116            .log_store
9117            .read(anchor.index())
9118            .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?
9119            .ok_or_else(|| {
9120                self.latch(NodeError::Invariant(
9121                    "read barrier anchor is neither retained nor compacted".into(),
9122                ))
9123            })?;
9124        if retained.hash != anchor.hash() {
9125            return Err(self.latch(NodeError::Invariant(
9126                "retained qlog hash differs from the read barrier anchor".into(),
9127            )));
9128        }
9129        Ok(())
9130    }
9131
9132    fn commit_read_barrier_locked(&self) -> Result<LogAnchor, NodeError> {
9133        self.ensure_writes_active()?;
9134        let context_read_fence = self.consensus.supports_context_read_fence();
9135        loop {
9136            self.ensure_ready()?;
9137            let (last_index, last_hash) = self.ensure_materialized_tip()?;
9138            let slot = last_index.checked_add(1).ok_or_else(|| {
9139                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
9140            })?;
9141            let inspection = if context_read_fence {
9142                match self
9143                    .consensus
9144                    .inspect_context_read_fence_at(slot, last_hash)
9145                    .map_err(|error| self.map_consensus_error(error))?
9146                {
9147                    CertifiedDecisionInspection::Committed(certified) => {
9148                        DecisionInspection::Committed(certified.entry)
9149                    }
9150                    CertifiedDecisionInspection::Empty => DecisionInspection::Empty,
9151                    CertifiedDecisionInspection::Pending => DecisionInspection::Pending,
9152                    CertifiedDecisionInspection::Unavailable => DecisionInspection::Unavailable,
9153                }
9154            } else {
9155                self.consensus
9156                    .inspect_decision_at(slot, last_hash)
9157                    .map_err(|error| self.map_consensus_error(error))?
9158            };
9159            match inspection {
9160                DecisionInspection::Committed(entry) => {
9161                    self.persist_entry(&entry, slot, last_hash)?;
9162                }
9163                DecisionInspection::Empty if context_read_fence => {
9164                    // Configuration may have been sealed while the read-only
9165                    // quorum observation was in flight. Never return an anchor
9166                    // from a configuration that is no longer write-active.
9167                    self.ensure_writes_active()?;
9168                    return Ok(LogAnchor::new(last_index, last_hash));
9169                }
9170                DecisionInspection::Pending => {
9171                    let entry = self
9172                        .consensus
9173                        .propose_at_cancellable(
9174                            slot,
9175                            last_hash,
9176                            Command::new(CommandKind::ReadBarrier, Vec::new()),
9177                            &self.operation_cancelled,
9178                        )
9179                        .map_err(|error| self.map_consensus_error(error))?;
9180                    self.persist_entry(&entry, slot, last_hash)?;
9181                    // Pending may conceal a historical phase-2 Noop whose
9182                    // asynchronous proof was never installed. It cannot fence
9183                    // this read, so inspect the following slot before returning.
9184                }
9185                DecisionInspection::Empty => {
9186                    let entry = self
9187                        .consensus
9188                        .propose_at_cancellable(
9189                            slot,
9190                            last_hash,
9191                            Command::new(CommandKind::ReadBarrier, Vec::new()),
9192                            &self.operation_cancelled,
9193                        )
9194                        .map_err(|error| self.map_consensus_error(error))?;
9195                    let is_barrier =
9196                        entry.entry_type == EntryType::Noop && entry.payload.is_empty();
9197                    self.persist_entry(&entry, slot, last_hash)?;
9198                    if is_barrier {
9199                        return Ok(LogAnchor::new(entry.index, entry.hash));
9200                    }
9201                }
9202                DecisionInspection::Unavailable => {
9203                    return Err(NodeError::Unavailable(
9204                        "decision inspection did not reach quorum".into(),
9205                    ));
9206                }
9207            }
9208        }
9209    }
9210
9211    #[cfg(feature = "sql")]
9212    fn check_request(
9213        &self,
9214        request_id: &str,
9215        payload: &[u8],
9216    ) -> Result<Option<RequestOutcome>, NodeError> {
9217        let sqlite = self.lock_sqlite()?;
9218        sqlite
9219            .check_request(request_id, payload)
9220            .map_err(|error| self.map_sqlite_error(error))
9221    }
9222
9223    #[cfg(feature = "graph")]
9224    #[cfg_attr(
9225        all(not(feature = "sql"), not(feature = "kv")),
9226        allow(irrefutable_let_patterns)
9227    )]
9228    fn check_graph_request(
9229        &self,
9230        request_id: &str,
9231        payload: &[u8],
9232    ) -> Result<Option<GraphRequestRecord>, NodeError> {
9233        let materializer = self.lock_materializer()?;
9234        let Materializer::Graph(graph) = &*materializer else {
9235            return Err(NodeError::ExecutionProfileMismatch {
9236                expected: ExecutionProfile::Graph,
9237                actual: materializer.profile(),
9238            });
9239        };
9240        graph
9241            .check_request(request_id, payload)
9242            .map_err(|error| NodeError::InvalidRequest(error.to_string()))
9243    }
9244
9245    #[cfg(feature = "kv")]
9246    #[cfg_attr(
9247        all(not(feature = "sql"), not(feature = "graph")),
9248        allow(irrefutable_let_patterns)
9249    )]
9250    fn check_kv_request(
9251        &self,
9252        request_id: &str,
9253        payload: &[u8],
9254    ) -> Result<Option<KvRequestRecord>, NodeError> {
9255        let materializer = self.lock_materializer()?;
9256        let Materializer::Kv(kv) = &*materializer else {
9257            return Err(NodeError::ExecutionProfileMismatch {
9258                expected: ExecutionProfile::Kv,
9259                actual: materializer.profile(),
9260            });
9261        };
9262        kv.check_request(request_id, payload)
9263            .map_err(|error| NodeError::InvalidRequest(error.to_string()))
9264    }
9265
9266    fn ensure_materialized_tip(&self) -> Result<(LogIndex, LogHash), NodeError> {
9267        #[cfg(test)]
9268        self.materialized_tip_checks.fetch_add(1, Ordering::Relaxed);
9269        let (last_index, last_hash) = self.durable_tip()?;
9270        let materializer = self.lock_materializer()?;
9271        let applied_tip = materializer
9272            .applied_tip()
9273            .map_err(|error| self.latch(NodeError::Storage(error)))?;
9274        let applied_index = applied_tip.index();
9275        let applied_hash = applied_tip.hash();
9276        if (applied_index, applied_hash) != (last_index, last_hash) {
9277            return Err(self.latch(NodeError::Invariant(format!(
9278                "qlog tip {last_index}/{} differs from {} materializer tip {applied_index}/{}",
9279                last_hash.to_hex(),
9280                materializer.profile(),
9281                applied_hash.to_hex()
9282            ))));
9283        }
9284        Ok((last_index, last_hash))
9285    }
9286
9287    fn durable_tip(&self) -> Result<(LogIndex, LogHash), NodeError> {
9288        static_log_tip(&self.log_store).map_err(|error| self.latch(error))
9289    }
9290
9291    fn persist_entry(
9292        &self,
9293        entry: &LogEntry,
9294        expected_index: LogIndex,
9295        expected_prev_hash: LogHash,
9296    ) -> Result<Option<SqlCommandResult>, NodeError> {
9297        let configuration_state = self.configuration_state()?;
9298        validate_runtime_entry(
9299            &self.config,
9300            &configuration_state,
9301            entry,
9302            expected_index,
9303            expected_prev_hash,
9304        )
9305        .map_err(|error| self.latch(error))?;
9306        if matches!(
9307            self.config.execution_profile,
9308            ExecutionProfile::Sqlite | ExecutionProfile::Kv
9309        ) {
9310            // SQL/KV embed the complete entry in their durable materializer state. The file qlog
9311            // remains a buffered serving mirror and is rehydrated on startup.
9312            self.log_store
9313                .append_batch_buffered(std::slice::from_ref(entry))
9314                .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9315        } else {
9316            self.log_store
9317                .append(entry)
9318                .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9319        }
9320        self.lock_materializer()?
9321            .apply_entry(entry)
9322            .map_err(|error| self.latch(NodeError::Invariant(error)))
9323    }
9324
9325    #[cfg(feature = "sql")]
9326    fn persist_sql_entry_profiled<P: SqlWritePhaseProfile>(
9327        &self,
9328        entry: &LogEntry,
9329        expected_index: LogIndex,
9330        expected_prev_hash: LogHash,
9331        profile: &mut P,
9332    ) -> Result<Option<SqlCommandResult>, NodeError> {
9333        let configuration_state = self.configuration_state()?;
9334        validate_runtime_entry(
9335            &self.config,
9336            &configuration_state,
9337            entry,
9338            expected_index,
9339            expected_prev_hash,
9340        )
9341        .map_err(|error| self.latch(error))?;
9342
9343        let qlog_mark = profile.mark();
9344        let append_result = self
9345            .log_store
9346            .append_batch_buffered(std::slice::from_ref(entry))
9347            .map_err(|error| self.latch(NodeError::Storage(error.to_string())));
9348        profile.add_local_qlog_mirror_append(qlog_mark);
9349        append_result?;
9350
9351        let materializer_mark = profile.mark();
9352        let apply_result = self
9353            .lock_materializer()?
9354            .apply_entry(entry)
9355            .map_err(|error| self.latch(NodeError::Invariant(error)));
9356        profile.add_sql_materializer_apply(materializer_mark);
9357        apply_result
9358    }
9359
9360    fn require_execution_profile(&self, expected: ExecutionProfile) -> Result<(), NodeError> {
9361        if self.config.execution_profile == expected {
9362            Ok(())
9363        } else {
9364            Err(NodeError::ExecutionProfileMismatch {
9365                expected,
9366                actual: self.config.execution_profile,
9367            })
9368        }
9369    }
9370
9371    fn ensure_ready(&self) -> Result<(), NodeError> {
9372        if self.fatal.load(Ordering::Acquire) {
9373            return Err(NodeError::Fatal(
9374                self.fatal_reason()
9375                    .unwrap_or_else(|| "fatal state is latched".into()),
9376            ));
9377        }
9378        if !self.ready.load(Ordering::Acquire) {
9379            return Err(NodeError::Unavailable("runtime is not ready".into()));
9380        }
9381        Ok(())
9382    }
9383
9384    fn ensure_writes_active(&self) -> Result<(), NodeError> {
9385        let state = self.configuration_state()?;
9386        if state.is_active() {
9387            Ok(())
9388        } else {
9389            Err(NodeError::ConfigurationTransition {
9390                state: Box::new(state),
9391            })
9392        }
9393    }
9394
9395    fn lock_commit(&self) -> Result<MutexGuard<'_, ()>, NodeError> {
9396        self.commit
9397            .lock()
9398            .map_err(|_| self.latch(NodeError::Invariant("commit mutex is poisoned".into())))
9399    }
9400
9401    fn lock_materializer(&self) -> Result<MutexGuard<'_, Materializer>, NodeError> {
9402        self.materializer.lock().map_err(|_| {
9403            self.latch(NodeError::Invariant(
9404                "materializer mutex is poisoned".into(),
9405            ))
9406        })
9407    }
9408
9409    #[cfg(feature = "graph")]
9410    #[cfg_attr(
9411        all(not(feature = "sql"), not(feature = "kv")),
9412        allow(irrefutable_let_patterns)
9413    )]
9414    fn graph_materializer(&self) -> Result<Arc<LadybugStateMachine>, NodeError> {
9415        let materializer = self.lock_materializer()?;
9416        let Materializer::Graph(graph) = &*materializer else {
9417            return Err(NodeError::ExecutionProfileMismatch {
9418                expected: ExecutionProfile::Graph,
9419                actual: materializer.profile(),
9420            });
9421        };
9422        Ok(Arc::clone(graph))
9423    }
9424
9425    #[cfg(feature = "kv")]
9426    #[cfg_attr(
9427        all(not(feature = "sql"), not(feature = "graph")),
9428        allow(irrefutable_let_patterns)
9429    )]
9430    fn kv_materializer(&self) -> Result<Arc<RedbStateMachine>, NodeError> {
9431        let materializer = self.lock_materializer()?;
9432        let Materializer::Kv(kv) = &*materializer else {
9433            return Err(NodeError::ExecutionProfileMismatch {
9434                expected: ExecutionProfile::Kv,
9435                actual: materializer.profile(),
9436            });
9437        };
9438        Ok(Arc::clone(kv))
9439    }
9440
9441    #[cfg(feature = "sql")]
9442    fn lock_sqlite(&self) -> Result<SqlMaterializerGuard<'_>, NodeError> {
9443        let guard = self.lock_materializer()?;
9444        if !matches!(&*guard, Materializer::Sql(_)) {
9445            return Err(NodeError::ExecutionProfileMismatch {
9446                expected: ExecutionProfile::Sqlite,
9447                actual: guard.profile(),
9448            });
9449        }
9450        Ok(SqlMaterializerGuard(guard))
9451    }
9452
9453    #[cfg(feature = "sql")]
9454    fn map_sqlite_error(&self, error: rhiza_sql::Error) -> NodeError {
9455        match error {
9456            rhiza_sql::Error::RequestConflict(conflict) => NodeError::RequestConflict(conflict),
9457            rhiza_sql::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9458            rhiza_sql::Error::InvalidCommand(message)
9459            | rhiza_sql::Error::IdentityMismatch(message)
9460            | rhiza_sql::Error::InvalidEntry(message)
9461            | rhiza_sql::Error::InvalidSnapshot(message) => {
9462                self.latch(NodeError::Invariant(message))
9463            }
9464            other => self.latch(NodeError::Storage(other.to_string())),
9465        }
9466    }
9467
9468    #[cfg(feature = "graph")]
9469    fn map_graph_read_error(&self, error: rhiza_graph::Error) -> NodeError {
9470        match error {
9471            rhiza_graph::Error::InvalidCommand(_) => NodeError::InvalidRequest(error.to_string()),
9472            rhiza_graph::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9473            rhiza_graph::Error::Ladybug(_) | rhiza_graph::Error::Io(_) => {
9474                self.latch(NodeError::Storage(error.to_string()))
9475            }
9476            rhiza_graph::Error::Closed
9477            | rhiza_graph::Error::Codec(_)
9478            | rhiza_graph::Error::InvalidEntry(_)
9479            | rhiza_graph::Error::IdentityMismatch(_)
9480            | rhiza_graph::Error::RequestConflict { .. }
9481            | rhiza_graph::Error::InvalidSnapshot(_) => {
9482                self.latch(NodeError::Invariant(error.to_string()))
9483            }
9484        }
9485    }
9486
9487    #[cfg(feature = "kv")]
9488    fn map_kv_read_error(&self, error: rhiza_kv::Error) -> NodeError {
9489        match error {
9490            rhiza_kv::Error::InvalidCommand(_) | rhiza_kv::Error::InvalidQuery(_) => {
9491                NodeError::InvalidRequest(error.to_string())
9492            }
9493            rhiza_kv::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9494            rhiza_kv::Error::Database(_) | rhiza_kv::Error::Io(_) => {
9495                self.latch(NodeError::Storage(error.to_string()))
9496            }
9497            rhiza_kv::Error::Codec(_)
9498            | rhiza_kv::Error::InvalidEntry(_)
9499            | rhiza_kv::Error::PartialInitialization
9500            | rhiza_kv::Error::RequestConflict { .. }
9501            | rhiza_kv::Error::InvalidSnapshot(_) => {
9502                self.latch(NodeError::Invariant(error.to_string()))
9503            }
9504        }
9505    }
9506
9507    fn map_consensus_error(&self, error: rhiza_quepaxa::Error) -> NodeError {
9508        match error {
9509            rhiza_quepaxa::Error::NoQuorum
9510            | rhiza_quepaxa::Error::ProposeFailed
9511            | rhiza_quepaxa::Error::CommandUnavailable
9512            | rhiza_quepaxa::Error::Cancelled
9513            | rhiza_quepaxa::Error::Io(_) => NodeError::Unavailable(error.to_string()),
9514            rhiza_quepaxa::Error::ConflictingCertificates
9515            | rhiza_quepaxa::Error::ChainConflict { .. } => {
9516                self.latch(NodeError::Reconciliation(error.to_string()))
9517            }
9518            other => self.latch(NodeError::Invariant(other.to_string())),
9519        }
9520    }
9521
9522    fn latch(&self, error: NodeError) -> NodeError {
9523        self.ready.store(false, Ordering::Release);
9524        if !self.fatal.swap(true, Ordering::AcqRel) {
9525            eprintln!(
9526                "node runtime entered fatal state: {}",
9527                escaped_error_detail(&error)
9528            );
9529            if let Ok(mut reason) = self.fatal_reason.lock() {
9530                *reason = Some(error.to_string());
9531            }
9532        }
9533        error
9534    }
9535}
9536
9537pub fn rehydrate_recorder_after_checkpoint(
9538    runtime: &NodeRuntime,
9539    recorder: &RecorderFileStore,
9540    checkpoint_index: LogIndex,
9541    startup: &StartupIoContext,
9542) -> Result<(), NodeError> {
9543    startup.check("recorder rehydration anchor validation")?;
9544    if let Some(anchor) = runtime
9545        .log_store()
9546        .logical_state()
9547        .map_err(|error| NodeError::Storage(error.to_string()))?
9548        .anchor
9549    {
9550        if checkpoint_index < anchor.compacted().index() {
9551            return Err(NodeError::SnapshotRequired(Box::new(anchor)));
9552        }
9553    }
9554    let applied_index = runtime.applied_index()?;
9555    if checkpoint_index > applied_index {
9556        return Err(NodeError::Reconciliation(format!(
9557            "checkpoint tip {checkpoint_index} is ahead of local applied index {applied_index}"
9558        )));
9559    }
9560
9561    for index in checkpoint_index.saturating_add(1)..=applied_index {
9562        startup.check("recorder rehydration qlog read")?;
9563        let entry = runtime
9564            .log_store()
9565            .read(index)
9566            .map_err(|error| NodeError::Storage(error.to_string()))?
9567            .ok_or_else(|| {
9568                NodeError::Reconciliation(format!(
9569                    "qlog entry {index} is missing during recorder rehydration"
9570                ))
9571            })?;
9572        startup.check("recorder rehydration decision inspection")?;
9573        let certified = match runtime
9574            .consensus()
9575            .inspect_certified_decision_at(index, entry.prev_hash)
9576            .map_err(startup_consensus_error)?
9577        {
9578            CertifiedDecisionInspection::Committed(certified) => certified,
9579            CertifiedDecisionInspection::Empty => {
9580                return Err(NodeError::Reconciliation(format!(
9581                    "qlog entry {index} has no recorder decision certificate"
9582                )))
9583            }
9584            CertifiedDecisionInspection::Pending => {
9585                return Err(NodeError::Reconciliation(format!(
9586                    "qlog entry {index} has only a pending recorder decision"
9587                )))
9588            }
9589            CertifiedDecisionInspection::Unavailable => {
9590                return Err(NodeError::Unavailable(format!(
9591                    "recorder decision certificate is unavailable at qlog index {index}"
9592                )))
9593            }
9594        };
9595        startup.check("recorder rehydration decision inspection")?;
9596        if certified.entry != entry {
9597            return Err(NodeError::Reconciliation(format!(
9598                "recorder decision certificate differs from qlog entry {index}"
9599            )));
9600        }
9601        let command = StoredCommand::new(entry.entry_type, entry.payload.clone());
9602        let proof = certified.proof.clone();
9603        startup.persist("recorder rehydration persistence", || {
9604            recorder
9605                .store_command(command.hash(), command)
9606                .map_err(|error| {
9607                    NodeError::Reconciliation(format!(
9608                        "cannot restore recorder command at qlog index {index}: {error}"
9609                    ))
9610                })?;
9611            recorder
9612                .install_decision_proof_record(proof, runtime.consensus().membership())
9613                .map_err(|error| {
9614                    NodeError::Reconciliation(format!(
9615                        "cannot install recorder decision at qlog index {index}: {error}"
9616                    ))
9617                })
9618        })?;
9619    }
9620    Ok(())
9621}
9622
9623#[cfg(test)]
9624mod tests {
9625    use axum::http::HeaderValue;
9626
9627    use rhiza_core::{
9628        Command, CommandKind, ConfigurationState, EntryType, ErrorCategory, ErrorClassification,
9629        ExecutionProfile, LogAnchor, LogHash, RecoveryAnchor, SnapshotIdentity, StoredCommand,
9630    };
9631    #[cfg(feature = "graph")]
9632    use rhiza_graph::{GraphCommandV1, GraphValueV1};
9633    #[cfg(feature = "kv")]
9634    use rhiza_kv::KvCommandV1;
9635    use rhiza_log::LogStore as _;
9636    use rhiza_quepaxa::{
9637        AcceptedValue, Membership, Proposal, ProposalPriority, RecordRequest, RecordSummary,
9638        RecorderFileStore, RecorderRpc, ThreeNodeConsensus,
9639    };
9640    use std::sync::{
9641        atomic::{AtomicBool, AtomicUsize, Ordering},
9642        mpsc, Arc, Barrier, Condvar, Mutex,
9643    };
9644
9645    #[cfg(any(feature = "sql", feature = "graph", feature = "kv"))]
9646    use super::node_error_response;
9647    #[cfg(feature = "graph")]
9648    use super::with_graph_client_permit;
9649    use super::ReadBarrierRounds;
9650    use super::{
9651        client_authenticated, next_sync_flush_retry, run_read_operation, sql_query_http_response,
9652        valid_recorder_record, Duration, FileLogStore, HeaderMap, Instant, NodeError,
9653        ReadConsistency, SqlCommand, SqlQueryResponse, SqlStatement, SqlValue, SqlWriteProfiler,
9654        MAX_COMMAND_BYTES, MAX_SQL_RESPONSE_BYTES, PROTOCOL_VERSION, QWAL_V3_MAGIC,
9655        SYNC_FLUSH_RETRY_INITIAL, VERSION_HEADER,
9656    };
9657    use super::{ConfigError, NodeConfig, NodeRuntime, NodeService, StartupIoContext};
9658
9659    struct BlockingStartupInspection {
9660        recorder: RecorderFileStore,
9661        started: mpsc::SyncSender<()>,
9662        gate: Arc<(Mutex<bool>, Condvar)>,
9663    }
9664
9665    impl RecorderRpc for BlockingStartupInspection {
9666        fn recorder_id(&self) -> rhiza_quepaxa::Result<String> {
9667            self.recorder.recorder_id()
9668        }
9669
9670        fn inspect_record_summary(
9671            &self,
9672            slot: u64,
9673        ) -> rhiza_quepaxa::Result<Option<RecordSummary>> {
9674            self.started.send(()).unwrap();
9675            let (released, condition) = &*self.gate;
9676            let mut released = released.lock().unwrap();
9677            while !*released {
9678                released = condition.wait(released).unwrap();
9679            }
9680            self.recorder.inspect_record_summary(slot)
9681        }
9682
9683        fn fetch_command_for(
9684            &self,
9685            cluster_id: String,
9686            epoch: u64,
9687            config_id: u64,
9688            config_digest: LogHash,
9689            command_hash: LogHash,
9690        ) -> rhiza_quepaxa::Result<Option<StoredCommand>> {
9691            self.recorder.fetch_command_for(
9692                cluster_id,
9693                epoch,
9694                config_id,
9695                config_digest,
9696                command_hash,
9697            )
9698        }
9699    }
9700
9701    #[test]
9702    fn runtime_open_does_not_persist_a_decision_returned_after_cancellation() {
9703        let root = tempfile::tempdir().unwrap();
9704        let node_ids = ["n1", "n2", "n3"];
9705        let membership = Membership::new(node_ids).unwrap();
9706        let recorders = node_ids
9707            .iter()
9708            .map(|node_id| {
9709                (
9710                    (*node_id).to_owned(),
9711                    RecorderFileStore::new_with_membership(
9712                        root.path().join("recorders").join(node_id),
9713                        *node_id,
9714                        "rhiza:sql:startup-cancel-test",
9715                        1,
9716                        1,
9717                        membership.clone(),
9718                    )
9719                    .unwrap(),
9720                )
9721            })
9722            .collect::<Vec<_>>();
9723        let seed_consensus = ThreeNodeConsensus::from_recorders_with_ids(
9724            "rhiza:sql:startup-cancel-test",
9725            "n1",
9726            1,
9727            1,
9728            recorders
9729                .iter()
9730                .map(|(id, recorder)| {
9731                    (
9732                        id.clone(),
9733                        Box::new(recorder.clone()) as Box<dyn RecorderRpc>,
9734                    )
9735                })
9736                .collect(),
9737        )
9738        .unwrap();
9739        seed_consensus
9740            .propose_at(
9741                1,
9742                LogHash::ZERO,
9743                Command::new(CommandKind::ReadBarrier, Vec::new()),
9744            )
9745            .unwrap();
9746        assert!(seed_consensus.finish_pending_rpcs(Duration::from_secs(1)));
9747        drop(seed_consensus);
9748
9749        let (started, blocked) = mpsc::sync_channel(3);
9750        let gate = Arc::new((Mutex::new(false), Condvar::new()));
9751        let consensus = Arc::new(
9752            ThreeNodeConsensus::from_recorders_with_ids(
9753                "rhiza:sql:startup-cancel-test",
9754                "n1",
9755                1,
9756                1,
9757                recorders
9758                    .into_iter()
9759                    .map(|(id, recorder)| {
9760                        (
9761                            id,
9762                            Box::new(BlockingStartupInspection {
9763                                recorder,
9764                                started: started.clone(),
9765                                gate: Arc::clone(&gate),
9766                            }) as Box<dyn RecorderRpc>,
9767                        )
9768                    })
9769                    .collect(),
9770            )
9771            .unwrap(),
9772        );
9773        let config = NodeConfig::new_embedded(
9774            "startup-cancel-test",
9775            "n1",
9776            root.path().join("node"),
9777            1,
9778            1,
9779            node_ids,
9780        )
9781        .unwrap();
9782        let startup = StartupIoContext::new();
9783        let attempt_startup = startup.clone();
9784        let attempt_config = config.clone();
9785        let attempt = std::thread::spawn(move || {
9786            NodeRuntime::open_cancellable(attempt_config, consensus, &[], &attempt_startup)
9787        });
9788
9789        blocked.recv().unwrap();
9790        startup.cancel(Instant::now() + Duration::from_secs(1));
9791        let (released, condition) = &*gate;
9792        *released.lock().unwrap() = true;
9793        condition.notify_all();
9794
9795        let error = attempt.join().unwrap().unwrap_err();
9796        assert!(
9797            error
9798                .to_string()
9799                .contains("startup cancelled during recorder decision inspection"),
9800            "{error}"
9801        );
9802        let log_store = FileLogStore::open_with_configuration(
9803            config.data_dir.join("consensus/log"),
9804            &config.cluster_id,
9805            config.epoch,
9806            config.log_initial_configuration.clone(),
9807        )
9808        .unwrap();
9809        assert_eq!(log_store.last_index().unwrap(), None);
9810    }
9811
9812    #[test]
9813    fn startup_cancellation_closes_mutation_admission_after_an_active_write_finishes() {
9814        let startup = StartupIoContext::new();
9815        let attempt_startup = startup.clone();
9816        let writes = Arc::new(AtomicUsize::new(0));
9817        let attempt_writes = Arc::clone(&writes);
9818        let (entered, active) = mpsc::sync_channel(1);
9819        let gate = Arc::new((Mutex::new(false), Condvar::new()));
9820        let attempt_gate = Arc::clone(&gate);
9821        let attempt = std::thread::spawn(move || {
9822            attempt_startup.persist("test persistence", || {
9823                entered.send(()).unwrap();
9824                let (released, condition) = &*attempt_gate;
9825                let mut released = released.lock().unwrap();
9826                while !*released {
9827                    released = condition.wait(released).unwrap();
9828                }
9829                attempt_writes.fetch_add(1, Ordering::AcqRel);
9830                Ok(())
9831            })
9832        });
9833
9834        active.recv().unwrap();
9835        startup.cancel(Instant::now() + Duration::from_secs(1));
9836        let (released, condition) = &*gate;
9837        *released.lock().unwrap() = true;
9838        condition.notify_all();
9839        attempt.join().unwrap().unwrap();
9840
9841        assert!(startup
9842            .persist("late persistence", || {
9843                writes.fetch_add(1, Ordering::AcqRel);
9844                Ok(())
9845            })
9846            .is_err());
9847        assert_eq!(writes.load(Ordering::Acquire), 1);
9848    }
9849
9850    #[test]
9851    fn embedded_config_accepts_matching_canonical_profile_ids() {
9852        for (cluster_id, profile) in [
9853            ("rhiza:graph:embedded", ExecutionProfile::Graph),
9854            ("rhiza:kv:embedded", ExecutionProfile::Kv),
9855        ] {
9856            let config = NodeConfig::new_embedded(
9857                cluster_id,
9858                "n1",
9859                std::env::temp_dir().join(profile.as_str()),
9860                1,
9861                1,
9862                ["n1", "n2", "n3"],
9863            )
9864            .unwrap()
9865            .with_execution_profile(profile)
9866            .unwrap();
9867
9868            assert_eq!(config.cluster_id(), cluster_id);
9869            assert_eq!(config.logical_cluster_id(), "embedded");
9870        }
9871    }
9872
9873    #[test]
9874    fn embedded_config_rejects_conflicting_canonical_profile_and_preserves_logical_ids() {
9875        let conflicting = NodeConfig::new_embedded(
9876            "rhiza:graph:embedded",
9877            "n1",
9878            std::env::temp_dir().join("conflicting-profile"),
9879            1,
9880            1,
9881            ["n1", "n2", "n3"],
9882        )
9883        .unwrap()
9884        .with_execution_profile(ExecutionProfile::Sqlite)
9885        .unwrap_err();
9886        assert!(matches!(
9887            conflicting,
9888            ConfigError::ClusterIdProfileMismatch {
9889                expected: ExecutionProfile::Sqlite,
9890                actual: ExecutionProfile::Graph,
9891            }
9892        ));
9893
9894        let logical = NodeConfig::new_embedded(
9895            "embedded",
9896            "n1",
9897            std::env::temp_dir().join("logical-profile"),
9898            1,
9899            1,
9900            ["n1", "n2", "n3"],
9901        )
9902        .unwrap();
9903        assert_eq!(logical.cluster_id(), "rhiza:sql:embedded");
9904        assert_eq!(logical.logical_cluster_id(), "embedded");
9905    }
9906
9907    #[test]
9908    fn node_error_classification_reports_observable_retry_semantics() {
9909        let cases = [
9910            (
9911                NodeError::InvalidRequest("missing key".into()),
9912                "invalid_request",
9913                ErrorCategory::InvalidRequest,
9914                false,
9915            ),
9916            (
9917                NodeError::PreconditionFailed("stale version".into()),
9918                "precondition_failed",
9919                ErrorCategory::Conflict,
9920                false,
9921            ),
9922            (
9923                NodeError::Unavailable("no quorum".into()),
9924                "unavailable",
9925                ErrorCategory::Unavailable,
9926                true,
9927            ),
9928            (
9929                NodeError::ResourceExhausted("result too large".into()),
9930                "resource_exhausted",
9931                ErrorCategory::ResourceExhausted,
9932                true,
9933            ),
9934            (
9935                NodeError::Invariant("invalid log".into()),
9936                "invariant_violation",
9937                ErrorCategory::Internal,
9938                false,
9939            ),
9940        ];
9941
9942        for (error, code, category, retryable) in cases {
9943            let classification = error.classification();
9944
9945            assert_eq!(classification.code(), code);
9946            assert_eq!(classification.category(), category);
9947            assert_eq!(classification.retryable(), retryable);
9948        }
9949    }
9950
9951    #[cfg(feature = "sql")]
9952    #[test]
9953    fn sql_batch_error_classification_preserves_statement_index_category() {
9954        let error = NodeError::InvalidSqlStatement {
9955            statement_index: 3,
9956            message: "syntax error".into(),
9957        };
9958
9959        let classification = error.classification();
9960
9961        assert_eq!(classification.code(), "invalid_request");
9962        assert_eq!(classification.category(), ErrorCategory::InvalidRequest);
9963        assert!(!classification.retryable());
9964    }
9965
9966    #[cfg(feature = "sql")]
9967    #[tokio::test]
9968    async fn node_error_http_response_preserves_contract() {
9969        let snapshot = RecoveryAnchor::new(
9970            "cluster",
9971            1,
9972            ConfigurationState::active(1, LogHash::digest(&[b"node-error-test-config"])),
9973            1,
9974            LogAnchor::new(1, LogHash::ZERO),
9975            SnapshotIdentity::new(
9976                "snapshot",
9977                LogHash::digest(&[b"node-error-test-snapshot"]),
9978                0,
9979                rhiza_sql::sql_executor_fingerprint().unwrap(),
9980            ),
9981        );
9982        let cases = vec![
9983            (
9984                NodeError::InvalidSqlStatement {
9985                    statement_index: 3,
9986                    message: "syntax error".into(),
9987                },
9988                axum::http::StatusCode::BAD_REQUEST,
9989                "invalid_request",
9990                false,
9991                Some(3),
9992            ),
9993            (
9994                NodeError::PreconditionFailed("stale version".into()),
9995                axum::http::StatusCode::CONFLICT,
9996                "precondition_failed",
9997                false,
9998                None,
9999            ),
10000            (
10001                NodeError::SnapshotRequired(Box::new(snapshot)),
10002                axum::http::StatusCode::SERVICE_UNAVAILABLE,
10003                "snapshot_required",
10004                false,
10005                None,
10006            ),
10007            (
10008                NodeError::Storage("disk failed".into()),
10009                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
10010                "storage_error",
10011                false,
10012                None,
10013            ),
10014        ];
10015
10016        for (node_error, status, code, retryable, statement_index) in cases {
10017            let response = node_error_response(node_error);
10018            assert_eq!(response.status(), status);
10019            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10020                .await
10021                .unwrap();
10022            let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
10023            let error: super::ClientErrorResponse = serde_json::from_value(value.clone()).unwrap();
10024            assert_eq!(error.code, code);
10025            assert_eq!(error.retryable, retryable);
10026            assert_eq!(error.statement_index, statement_index);
10027            assert!(value.get("category").is_none());
10028            assert!(matches!(
10029                error.message.as_str(),
10030                "request could not be processed"
10031                    | "request conflicts with current state"
10032                    | "service is temporarily unavailable"
10033                    | "internal server error"
10034            ));
10035        }
10036    }
10037
10038    #[cfg(feature = "sql")]
10039    #[tokio::test]
10040    async fn node_error_http_response_hides_display_details() {
10041        let detail = "/srv/rhiza/private/consensus/log: checksum mismatch";
10042        let cases = [
10043            (
10044                NodeError::Storage(detail.into()),
10045                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
10046                "storage_error",
10047                None,
10048                "internal server error",
10049            ),
10050            (
10051                NodeError::Reconciliation(detail.into()),
10052                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
10053                "reconciliation_error",
10054                None,
10055                "internal server error",
10056            ),
10057            (
10058                NodeError::InvalidSqlStatement {
10059                    statement_index: 7,
10060                    message: detail.into(),
10061                },
10062                axum::http::StatusCode::BAD_REQUEST,
10063                "invalid_request",
10064                Some(7),
10065                "request could not be processed",
10066            ),
10067        ];
10068
10069        for (node_error, status, code, statement_index, message) in cases {
10070            let response = node_error_response(node_error);
10071            assert_eq!(response.status(), status);
10072            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10073                .await
10074                .unwrap();
10075            let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10076            assert_eq!(error.code, code);
10077            assert_eq!(error.statement_index, statement_index);
10078            assert_eq!(error.message, message);
10079            assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
10080        }
10081    }
10082
10083    #[tokio::test]
10084    async fn client_json_error_response_uses_a_stable_message() {
10085        for (status, code) in [
10086            (axum::http::StatusCode::BAD_REQUEST, "invalid_json"),
10087            (
10088                axum::http::StatusCode::PAYLOAD_TOO_LARGE,
10089                "payload_too_large",
10090            ),
10091        ] {
10092            let response = super::client_json_error_response(status);
10093
10094            assert_eq!(response.status(), status);
10095            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10096                .await
10097                .unwrap();
10098            let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10099            assert_eq!(error.code, code);
10100            assert!(!error.retryable);
10101            assert_eq!(error.message, "request body is invalid");
10102            assert_eq!(error.statement_index, None);
10103        }
10104    }
10105
10106    #[tokio::test]
10107    async fn client_task_error_hides_join_error_details() {
10108        let detail = "private task detail\nforged log entry";
10109        let error = tokio::spawn(async move { panic!("{detail}") })
10110            .await
10111            .unwrap_err();
10112        let response = super::client_task_error(error);
10113
10114        assert_eq!(
10115            response.status(),
10116            axum::http::StatusCode::INTERNAL_SERVER_ERROR
10117        );
10118        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10119            .await
10120            .unwrap();
10121        let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10122        assert_eq!(error.code, "task_failed");
10123        assert!(!error.retryable);
10124        assert_eq!(error.message, "request task failed");
10125        assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
10126    }
10127
10128    #[cfg(feature = "sql")]
10129    #[tokio::test]
10130    async fn fatal_readiness_response_hides_fatal_reason() {
10131        let (_dir, runtime) = sql_test_runtime();
10132        let detail = "/srv/rhiza/private\nforged log entry";
10133        runtime.latch(NodeError::Storage(detail.into()));
10134        let response = super::runtime_readiness_response(&runtime).unwrap();
10135
10136        assert_eq!(
10137            response.status(),
10138            axum::http::StatusCode::INTERNAL_SERVER_ERROR
10139        );
10140        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10141            .await
10142            .unwrap();
10143        let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10144        assert_eq!(error.code, "fatal");
10145        assert!(!error.retryable);
10146        assert_eq!(error.message, "node is fatally unavailable");
10147        assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
10148    }
10149
10150    #[test]
10151    fn escaped_error_detail_escapes_control_characters() {
10152        let detail = "checksum mismatch\nforged entry\r\u{1b}[2J";
10153
10154        assert_eq!(
10155            super::escaped_error_detail(&detail),
10156            r"checksum mismatch\nforged entry\r\u{1b}[2J"
10157        );
10158    }
10159
10160    #[test]
10161    fn escaped_error_detail_bounds_escape_expansion() {
10162        let detail = "\n".repeat(super::MAX_ESCAPED_ERROR_DETAIL_BYTES / 2 + 1);
10163        let escaped = super::escaped_error_detail(&detail);
10164
10165        assert!(
10166            escaped.len() <= super::MAX_ESCAPED_ERROR_DETAIL_BYTES,
10167            "escaped detail must stay within the log budget"
10168        );
10169        assert!(
10170            escaped.ends_with(super::ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER),
10171            "truncated details must be explicit"
10172        );
10173        assert!(!escaped.contains('\n'));
10174    }
10175
10176    #[tokio::test]
10177    async fn client_error_responses_preserve_payload_and_authentication_wire_codes() {
10178        for (status, code, retryable, category) in [
10179            (
10180                axum::http::StatusCode::PAYLOAD_TOO_LARGE,
10181                "payload_too_large",
10182                false,
10183                ErrorCategory::ResourceExhausted,
10184            ),
10185            (
10186                axum::http::StatusCode::UNAUTHORIZED,
10187                "unauthorized",
10188                false,
10189                ErrorCategory::Authentication,
10190            ),
10191        ] {
10192            let response =
10193                super::client_error_response(status, code, retryable, "request failed", None);
10194            assert_eq!(response.status(), status);
10195            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10196                .await
10197                .unwrap();
10198            let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
10199            let error: super::ClientErrorResponse = serde_json::from_value(value.clone()).unwrap();
10200            assert_eq!(error.code, code);
10201            assert_eq!(error.retryable, retryable);
10202            assert!(value.get("category").is_none());
10203            assert_eq!(
10204                ErrorClassification::from_server_code(code, retryable).category(),
10205                category
10206            );
10207        }
10208    }
10209
10210    #[test]
10211    fn concurrent_read_barriers_registered_before_cutoff_share_one_generation() {
10212        let rounds = ReadBarrierRounds::new(Duration::ZERO);
10213        let cancelled = AtomicBool::new(false);
10214        let participants = (0..4).map(|_| rounds.join().unwrap()).collect::<Vec<_>>();
10215        let generation = participants[0].generation();
10216        assert!(participants[0].is_leader());
10217        assert!(participants[1..]
10218            .iter()
10219            .all(|participant| !participant.is_leader() && participant.generation() == generation));
10220
10221        let calls = AtomicUsize::new(0);
10222        let mut publication = participants[0].publication().unwrap();
10223        publication.wait_turn(&cancelled).unwrap();
10224        publication.start(&cancelled).unwrap();
10225        let anchor = LogAnchor::new(7, LogHash::digest(&[b"shared-barrier"]));
10226        calls.fetch_add(1, Ordering::Relaxed);
10227        publication.publish(Ok(anchor));
10228
10229        assert_eq!(calls.load(Ordering::Relaxed), 1);
10230        for participant in &participants[1..] {
10231            assert_eq!(participant.wait(&cancelled).unwrap(), anchor);
10232        }
10233    }
10234
10235    #[test]
10236    fn read_barrier_arriving_after_running_cutoff_uses_next_generation() {
10237        let rounds = Arc::new(ReadBarrierRounds::new(Duration::ZERO));
10238        let cancelled = Arc::new(AtomicBool::new(false));
10239        let first = rounds.join().unwrap();
10240        let first_generation = first.generation();
10241        let (running_tx, running_rx) = mpsc::channel();
10242        let (release_tx, release_rx) = mpsc::channel();
10243        let first_cancelled = Arc::clone(&cancelled);
10244        let first_worker = std::thread::spawn(move || {
10245            let mut publication = first.publication().unwrap();
10246            publication.wait_turn(&first_cancelled).unwrap();
10247            publication.start(&first_cancelled).unwrap();
10248            running_tx.send(()).unwrap();
10249            release_rx.recv().unwrap();
10250            let anchor = LogAnchor::new(1, LogHash::digest(&[b"first"]));
10251            publication.publish(Ok(anchor));
10252            anchor
10253        });
10254        running_rx.recv().unwrap();
10255
10256        let late = rounds.join().unwrap();
10257        assert!(late.is_leader());
10258        assert_eq!(late.generation(), first_generation + 1);
10259        release_tx.send(()).unwrap();
10260        assert_eq!(first_worker.join().unwrap().index(), 1);
10261
10262        let mut publication = late.publication().unwrap();
10263        publication.wait_turn(&cancelled).unwrap();
10264        publication.start(&cancelled).unwrap();
10265        let second = LogAnchor::new(2, LogHash::digest(&[b"second"]));
10266        publication.publish(Ok(second));
10267        assert_eq!(late.wait(&cancelled).unwrap(), second);
10268    }
10269
10270    #[test]
10271    fn completed_read_barrier_is_not_reused_and_predecessor_failure_retries_independently() {
10272        let rounds = ReadBarrierRounds::new(Duration::ZERO);
10273        let cancelled = AtomicBool::new(false);
10274        let failed = rounds.join().unwrap();
10275        let failed_generation = failed.generation();
10276        let mut publication = failed.publication().unwrap();
10277        publication.wait_turn(&cancelled).unwrap();
10278        publication.start(&cancelled).unwrap();
10279        publication.publish(Err(NodeError::Unavailable("no quorum".into())));
10280        assert!(matches!(
10281            failed.wait(&cancelled),
10282            Err(NodeError::Unavailable(_))
10283        ));
10284
10285        let retry = rounds.join().unwrap();
10286        assert!(retry.is_leader());
10287        assert_eq!(retry.generation(), failed_generation + 1);
10288        let mut publication = retry.publication().unwrap();
10289        publication.wait_turn(&cancelled).unwrap();
10290        publication.start(&cancelled).unwrap();
10291        let anchor = LogAnchor::new(1, LogHash::digest(&[b"retry"]));
10292        publication.publish(Ok(anchor));
10293        assert_eq!(retry.wait(&cancelled).unwrap(), anchor);
10294
10295        let later = rounds.join().unwrap();
10296        assert!(later.is_leader());
10297        assert_eq!(later.generation(), retry.generation() + 1);
10298    }
10299
10300    #[test]
10301    fn read_barrier_leader_drop_and_global_cancel_wake_waiters() {
10302        let rounds = Arc::new(ReadBarrierRounds::new(Duration::ZERO));
10303        let cancelled = Arc::new(AtomicBool::new(false));
10304        let abandoned = rounds.join().unwrap();
10305        let follower = rounds.join().unwrap();
10306        drop(abandoned.publication().unwrap());
10307        assert!(matches!(
10308            follower.wait(&cancelled),
10309            Err(NodeError::Unavailable(_))
10310        ));
10311
10312        let leader = rounds.join().unwrap();
10313        let waiting = rounds.join().unwrap();
10314        let waiting_cancelled = Arc::clone(&cancelled);
10315        let waiter = std::thread::spawn(move || waiting.wait(&waiting_cancelled));
10316        cancelled.store(true, Ordering::Release);
10317        rounds.cancel_waiters();
10318        assert!(matches!(
10319            waiter.join().unwrap(),
10320            Err(NodeError::Unavailable(_))
10321        ));
10322        drop(leader.publication().unwrap());
10323    }
10324
10325    #[test]
10326    fn sql_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
10327        let (_dir, mut runtime) = sql_test_runtime();
10328        runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
10329        let runtime = Arc::new(runtime);
10330        let start = Arc::new(Barrier::new(4));
10331        let workers = (0..4)
10332            .map(|_| {
10333                let runtime = Arc::clone(&runtime);
10334                let start = Arc::clone(&start);
10335                std::thread::spawn(move || {
10336                    start.wait();
10337                    runtime.read("missing", ReadConsistency::ReadBarrier)
10338                })
10339            })
10340            .collect::<Vec<_>>();
10341
10342        let responses = workers
10343            .into_iter()
10344            .map(|worker| worker.join().unwrap().unwrap())
10345            .collect::<Vec<_>>();
10346        assert!(responses
10347            .iter()
10348            .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
10349        assert_eq!(runtime.log_store().last_index().unwrap(), None);
10350    }
10351
10352    #[test]
10353    fn read_barrier_anchor_remains_valid_when_materialized_tip_advances() {
10354        let (_dir, runtime) = sql_test_runtime();
10355        let anchor = runtime.establish_read_barrier().unwrap();
10356        let write = runtime.write("request-1", "alpha", "one").unwrap();
10357        assert!(write.applied_index > anchor.index());
10358
10359        let _commit = runtime.lock_commit().unwrap();
10360        runtime.ensure_ready().unwrap();
10361        runtime.ensure_writes_active().unwrap();
10362        runtime
10363            .validate_read_barrier_descendant_locked(anchor)
10364            .unwrap();
10365        let read = runtime.read_local("alpha", Some(anchor.index())).unwrap();
10366
10367        assert_eq!(read.value.as_deref(), Some("one"));
10368        assert_eq!(read.applied_index, write.applied_index);
10369        assert_eq!(read.hash, write.hash);
10370    }
10371
10372    #[test]
10373    fn checkpoint_publication_does_not_close_runtime_readiness_or_writes() {
10374        let (_dir, runtime) = sql_test_runtime();
10375        runtime.checkpointing.store(true, Ordering::Release);
10376
10377        assert!(runtime.is_ready());
10378        runtime.ensure_ready().unwrap();
10379        let write = runtime.write("request-1", "alpha", "one").unwrap();
10380
10381        assert_eq!(write.applied_index, 1);
10382        assert_eq!(
10383            runtime.read("alpha", ReadConsistency::Local).unwrap().value,
10384            Some("one".into())
10385        );
10386    }
10387
10388    #[cfg(feature = "graph")]
10389    #[test]
10390    fn graph_read_barrier_checks_materialized_tip_once_before_snapshot() {
10391        let (_dir, runtime) = graph_test_runtime();
10392
10393        let response = runtime
10394            .get_graph_document("missing", ReadConsistency::ReadBarrier)
10395            .unwrap();
10396
10397        assert_eq!(response.applied_index, 0);
10398        assert_eq!(response.hash, LogHash::ZERO);
10399        assert_eq!(runtime.materialized_tip_checks.load(Ordering::Relaxed), 1);
10400    }
10401
10402    #[cfg(feature = "graph")]
10403    #[test]
10404    fn graph_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
10405        let (_dir, mut runtime) = graph_test_runtime();
10406        runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
10407        let runtime = Arc::new(runtime);
10408        let start = Arc::new(Barrier::new(4));
10409        let workers = (0..4)
10410            .map(|_| {
10411                let runtime = Arc::clone(&runtime);
10412                let start = Arc::clone(&start);
10413                std::thread::spawn(move || {
10414                    start.wait();
10415                    runtime.get_graph_document("missing", ReadConsistency::ReadBarrier)
10416                })
10417            })
10418            .collect::<Vec<_>>();
10419
10420        let responses = workers
10421            .into_iter()
10422            .map(|worker| worker.join().unwrap().unwrap())
10423            .collect::<Vec<_>>();
10424        assert!(responses
10425            .iter()
10426            .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
10427        assert_eq!(runtime.log_store().last_index().unwrap(), None);
10428    }
10429
10430    #[cfg(feature = "graph")]
10431    #[test]
10432    fn graph_read_barrier_releases_commit_lock_before_backend_snapshot() {
10433        let (_dir, mut runtime) = graph_test_runtime();
10434        let initial = runtime
10435            .mutate_graph(
10436                GraphCommandV1::put_document(
10437                    "request-1",
10438                    "document-1",
10439                    GraphValueV1::String("one".into()),
10440                )
10441                .unwrap(),
10442            )
10443            .unwrap();
10444        let entered = Arc::new(Barrier::new(2));
10445        let release = Arc::new(Barrier::new(2));
10446        runtime.read_barrier_before_snapshot_hook = Some(Arc::new({
10447            let entered = Arc::clone(&entered);
10448            let release = Arc::clone(&release);
10449            move || {
10450                entered.wait();
10451                release.wait();
10452            }
10453        }));
10454        let runtime = Arc::new(runtime);
10455        let reader = {
10456            let runtime = Arc::clone(&runtime);
10457            std::thread::spawn(move || {
10458                runtime.get_graph_document("document-1", ReadConsistency::ReadBarrier)
10459            })
10460        };
10461        entered.wait();
10462        let (advanced_tx, advanced_rx) = mpsc::channel();
10463        let writer = {
10464            let runtime = Arc::clone(&runtime);
10465            std::thread::spawn(move || {
10466                let outcome = runtime
10467                    .mutate_graph(
10468                        GraphCommandV1::put_document(
10469                            "request-2",
10470                            "document-1",
10471                            GraphValueV1::String("two".into()),
10472                        )
10473                        .unwrap(),
10474                    )
10475                    .unwrap();
10476                advanced_tx
10477                    .send((outcome.applied_index(), outcome.hash()))
10478                    .unwrap();
10479                outcome
10480            })
10481        };
10482
10483        let advanced_before_snapshot = advanced_rx.recv_timeout(Duration::from_secs(2));
10484        release.wait();
10485        let written = writer.join().unwrap();
10486        let read = reader.join().unwrap().unwrap();
10487
10488        assert!(
10489            advanced_before_snapshot.is_ok(),
10490            "graph write must advance while the read is paused before its backend snapshot"
10491        );
10492        assert!(read.applied_index >= initial.applied_index());
10493        if read.applied_index == initial.applied_index() {
10494            assert_eq!(read.hash, initial.hash());
10495        }
10496        assert_eq!(read.applied_index, written.applied_index());
10497        assert_eq!(read.hash, written.hash());
10498    }
10499
10500    #[cfg(feature = "graph")]
10501    #[test]
10502    fn read_barrier_rejects_same_index_snapshot_with_different_hash() {
10503        let (_dir, runtime) = graph_test_runtime();
10504        let anchor = LogAnchor::new(7, LogHash::digest(&[b"barrier-anchor"]));
10505        let observed = LogAnchor::new(7, LogHash::digest(&[b"divergent-snapshot"]));
10506
10507        assert!(matches!(
10508            runtime.validate_read_barrier_snapshot(anchor, observed),
10509            Err(NodeError::Invariant(message))
10510                if message.contains("snapshot tip hash differs")
10511        ));
10512        assert!(runtime.is_fatal());
10513    }
10514
10515    #[cfg(feature = "kv")]
10516    #[test]
10517    fn kv_read_barrier_checks_materialized_tip_once_before_snapshot() {
10518        let (_dir, runtime) = kv_test_runtime();
10519
10520        let response = runtime
10521            .get_kv(b"missing", ReadConsistency::ReadBarrier)
10522            .unwrap();
10523
10524        assert_eq!(response.applied_index, 0);
10525        assert_eq!(response.hash, LogHash::ZERO);
10526        assert_eq!(runtime.materialized_tip_checks.load(Ordering::Relaxed), 1);
10527    }
10528
10529    #[cfg(feature = "kv")]
10530    #[test]
10531    fn kv_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
10532        let (_dir, mut runtime) = kv_test_runtime();
10533        runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
10534        let runtime = Arc::new(runtime);
10535        let start = Arc::new(Barrier::new(4));
10536        let workers = (0..4)
10537            .map(|_| {
10538                let runtime = Arc::clone(&runtime);
10539                let start = Arc::clone(&start);
10540                std::thread::spawn(move || {
10541                    start.wait();
10542                    runtime.get_kv(b"missing", ReadConsistency::ReadBarrier)
10543                })
10544            })
10545            .collect::<Vec<_>>();
10546
10547        let responses = workers
10548            .into_iter()
10549            .map(|worker| worker.join().unwrap().unwrap())
10550            .collect::<Vec<_>>();
10551        assert!(responses
10552            .iter()
10553            .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
10554        assert_eq!(runtime.log_store().last_index().unwrap(), None);
10555    }
10556
10557    #[cfg(feature = "kv")]
10558    #[test]
10559    fn kv_read_barrier_releases_commit_lock_before_backend_snapshot() {
10560        let (_dir, mut runtime) = kv_test_runtime();
10561        let initial = runtime
10562            .mutate_kv(KvCommandV1::put("request-1", b"key".to_vec(), b"one".to_vec()).unwrap())
10563            .unwrap();
10564        let entered = Arc::new(Barrier::new(2));
10565        let release = Arc::new(Barrier::new(2));
10566        runtime.read_barrier_before_snapshot_hook = Some(Arc::new({
10567            let entered = Arc::clone(&entered);
10568            let release = Arc::clone(&release);
10569            move || {
10570                entered.wait();
10571                release.wait();
10572            }
10573        }));
10574        let runtime = Arc::new(runtime);
10575        let reader = {
10576            let runtime = Arc::clone(&runtime);
10577            std::thread::spawn(move || runtime.get_kv(b"key", ReadConsistency::ReadBarrier))
10578        };
10579        entered.wait();
10580        let (advanced_tx, advanced_rx) = mpsc::channel();
10581        let writer = {
10582            let runtime = Arc::clone(&runtime);
10583            std::thread::spawn(move || {
10584                let outcome = runtime
10585                    .mutate_kv(
10586                        KvCommandV1::put("request-2", b"key".to_vec(), b"two".to_vec()).unwrap(),
10587                    )
10588                    .unwrap();
10589                advanced_tx
10590                    .send((outcome.applied_index(), outcome.hash()))
10591                    .unwrap();
10592                outcome
10593            })
10594        };
10595
10596        let advanced_before_snapshot = advanced_rx.recv_timeout(Duration::from_secs(2));
10597        release.wait();
10598        let written = writer.join().unwrap();
10599        let read = reader.join().unwrap().unwrap();
10600
10601        assert!(
10602            advanced_before_snapshot.is_ok(),
10603            "KV write must advance while the read is paused before its backend snapshot"
10604        );
10605        assert!(read.applied_index >= initial.applied_index());
10606        if read.applied_index == initial.applied_index() {
10607            assert_eq!(read.hash, initial.hash());
10608        }
10609        assert_eq!(read.applied_index, written.applied_index());
10610        assert_eq!(read.hash, written.hash());
10611    }
10612
10613    #[test]
10614    fn client_authentication_rejects_empty_expected_token() {
10615        let mut headers = HeaderMap::new();
10616        headers.insert(VERSION_HEADER, HeaderValue::from_static(PROTOCOL_VERSION));
10617        headers.insert("authorization", HeaderValue::from_static("Bearer "));
10618
10619        assert!(!client_authenticated(&headers, ""));
10620    }
10621
10622    #[test]
10623    fn recorder_record_rejects_oversized_inline_command() {
10624        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
10625        let command = StoredCommand::new(
10626            EntryType::Command,
10627            vec![0_u8; MAX_COMMAND_BYTES.saturating_add(1)],
10628        );
10629        let request = RecordRequest {
10630            cluster_id: "rhiza:sql:node-unit-test".into(),
10631            epoch: 1,
10632            config_id: 1,
10633            config_digest: membership.digest(),
10634            slot: 1,
10635            step: 1,
10636            proposal: Proposal::new(
10637                ProposalPriority::MAX,
10638                "n1",
10639                1,
10640                AcceptedValue::from_command(
10641                    "rhiza:sql:node-unit-test",
10642                    1,
10643                    1,
10644                    1,
10645                    LogHash::ZERO,
10646                    &command,
10647                ),
10648            ),
10649            command: Some(command),
10650        };
10651
10652        assert!(!valid_recorder_record(&request));
10653    }
10654
10655    #[test]
10656    fn sync_flush_retry_doubles_to_a_jitter_free_cap() {
10657        let mut delay = SYNC_FLUSH_RETRY_INITIAL;
10658        let mut delays = Vec::new();
10659        for _ in 0..7 {
10660            delays.push(delay);
10661            delay = next_sync_flush_retry(delay);
10662        }
10663
10664        assert_eq!(
10665            delays,
10666            [50, 100, 200, 400, 800, 1_000, 1_000].map(Duration::from_millis)
10667        );
10668    }
10669
10670    #[test]
10671    fn blocking_operation_offloads_on_current_thread_runtime() {
10672        let runtime = tokio::runtime::Builder::new_current_thread()
10673            .build()
10674            .unwrap();
10675        runtime.block_on(async {
10676            let caller = std::thread::current().id();
10677            let worker = run_read_operation(ReadConsistency::Local, || std::thread::current().id())
10678                .await
10679                .unwrap();
10680
10681            assert_ne!(worker, caller);
10682        });
10683    }
10684
10685    #[test]
10686    fn blocking_operation_runs_inline_on_multi_thread_runtime() {
10687        let runtime = tokio::runtime::Builder::new_multi_thread()
10688            .worker_threads(2)
10689            .build()
10690            .unwrap();
10691        runtime.block_on(async {
10692            let caller = std::thread::current().id();
10693            let worker = run_read_operation(ReadConsistency::AppliedIndex(1), || {
10694                std::thread::current().id()
10695            })
10696            .await
10697            .unwrap();
10698
10699            assert_eq!(worker, caller);
10700        });
10701    }
10702
10703    #[test]
10704    fn read_barrier_offloads_on_multi_thread_runtime() {
10705        let runtime = tokio::runtime::Builder::new_multi_thread()
10706            .worker_threads(2)
10707            .build()
10708            .unwrap();
10709        runtime.block_on(async {
10710            let caller = std::thread::current().id();
10711            let worker =
10712                run_read_operation(ReadConsistency::ReadBarrier, || std::thread::current().id())
10713                    .await
10714                    .unwrap();
10715
10716            assert_ne!(worker, caller);
10717        });
10718    }
10719
10720    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10721    async fn node_service_adaptive_sql_read_returns_point_and_query_results() {
10722        let (_dir, runtime) = sql_test_runtime();
10723        let service = NodeService::new(Arc::new(runtime), None);
10724
10725        let point = service
10726            .read("missing", ReadConsistency::Local)
10727            .await
10728            .unwrap();
10729        let query = service
10730            .query(
10731                SqlStatement {
10732                    sql: "SELECT ?1 AS value".into(),
10733                    parameters: vec![SqlValue::Integer(7)],
10734                },
10735                ReadConsistency::AppliedIndex(0),
10736                1,
10737            )
10738            .await
10739            .unwrap();
10740
10741        assert_eq!(point.value, None);
10742        assert_eq!(query.columns, vec!["value"]);
10743        assert_eq!(query.rows, vec![vec![SqlValue::Integer(7)]]);
10744    }
10745
10746    #[test]
10747    fn node_service_adaptive_sql_read_stays_inline_and_recovers_direct_panic() {
10748        let (_dir, runtime) = sql_test_runtime();
10749        let service = NodeService::new(Arc::new(runtime), None);
10750        let runtime = tokio::runtime::Builder::new_multi_thread()
10751            .worker_threads(2)
10752            .build()
10753            .unwrap();
10754        runtime.block_on(async {
10755            let caller = std::thread::current().id();
10756            let worker = service
10757                .run_sql_read_operation(ReadConsistency::Local, || std::thread::current().id())
10758                .await
10759                .unwrap();
10760
10761            assert_eq!(worker, caller);
10762            assert_eq!(
10763                service
10764                    .sql_reads_in_flight
10765                    .load(std::sync::atomic::Ordering::Acquire),
10766                0
10767            );
10768        });
10769
10770        let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
10771            runtime.block_on(
10772                service.run_sql_read_operation(ReadConsistency::AppliedIndex(0), || -> () {
10773                    panic!("inline SQL read panic")
10774                }),
10775            )
10776        }));
10777        assert!(panic.is_err());
10778        assert_eq!(
10779            service
10780                .sql_reads_in_flight
10781                .load(std::sync::atomic::Ordering::Acquire),
10782            0
10783        );
10784    }
10785
10786    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10787    async fn node_service_adaptive_sql_read_offloads_overlap_and_recovers_join_error() {
10788        let (_dir, runtime) = sql_test_runtime();
10789        let service = NodeService::new(Arc::new(runtime), None);
10790        let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
10791        let (release_tx, release_rx) = std::sync::mpsc::channel();
10792        let first_service = service.clone();
10793        let first = tokio::spawn(async move {
10794            first_service
10795                .run_sql_read_operation(ReadConsistency::Local, move || {
10796                    entered_tx.send(()).unwrap();
10797                    release_rx.recv().unwrap();
10798                })
10799                .await
10800                .unwrap();
10801        });
10802        entered_rx.await.unwrap();
10803        assert_eq!(
10804            service
10805                .sql_reads_in_flight
10806                .load(std::sync::atomic::Ordering::Acquire),
10807            1
10808        );
10809
10810        let caller = std::thread::current().id();
10811        let worker = service
10812            .run_sql_read_operation(ReadConsistency::AppliedIndex(0), || {
10813                std::thread::current().id()
10814            })
10815            .await
10816            .unwrap();
10817        assert_ne!(worker, caller);
10818        assert_eq!(
10819            service
10820                .sql_reads_in_flight
10821                .load(std::sync::atomic::Ordering::Acquire),
10822            1
10823        );
10824
10825        let error = service
10826            .run_sql_read_operation(ReadConsistency::Local, || -> () {
10827                panic!("contended SQL read panic")
10828            })
10829            .await
10830            .unwrap_err();
10831        assert!(error.is_panic());
10832        assert_eq!(
10833            service
10834                .sql_reads_in_flight
10835                .load(std::sync::atomic::Ordering::Acquire),
10836            1
10837        );
10838
10839        release_tx.send(()).unwrap();
10840        first.await.unwrap();
10841        assert_eq!(
10842            service
10843                .sql_reads_in_flight
10844                .load(std::sync::atomic::Ordering::Acquire),
10845            0
10846        );
10847    }
10848
10849    #[test]
10850    fn node_service_adaptive_sql_read_offloads_on_current_thread() {
10851        let (_dir, node) = sql_test_runtime();
10852        let service = NodeService::new(Arc::new(node), None);
10853        let runtime = tokio::runtime::Builder::new_current_thread()
10854            .build()
10855            .unwrap();
10856        runtime.block_on(async {
10857            let caller = std::thread::current().id();
10858            let worker = service
10859                .run_sql_read_operation(ReadConsistency::Local, || std::thread::current().id())
10860                .await
10861                .unwrap();
10862
10863            assert_ne!(worker, caller);
10864            assert_eq!(
10865                service
10866                    .sql_reads_in_flight
10867                    .load(std::sync::atomic::Ordering::Acquire),
10868                0
10869            );
10870        });
10871    }
10872
10873    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10874    async fn node_service_read_barrier_offloads_without_counting_fast_reads() {
10875        let (_dir, runtime) = sql_test_runtime();
10876        let service = NodeService::new(Arc::new(runtime), None);
10877        let caller = std::thread::current().id();
10878        let worker = service
10879            .run_sql_read_operation(ReadConsistency::ReadBarrier, || std::thread::current().id())
10880            .await
10881            .unwrap();
10882
10883        assert_ne!(worker, caller);
10884        assert_eq!(
10885            service
10886                .sql_reads_in_flight
10887                .load(std::sync::atomic::Ordering::Acquire),
10888            0
10889        );
10890    }
10891
10892    #[test]
10893    fn embedded_sql_query_keeps_raw_budget_without_http_json_budget() {
10894        let (_dir, runtime) = sql_test_runtime();
10895        let response = runtime
10896            .query_sql(
10897                &SqlStatement {
10898                    sql: "SELECT replace(hex(zeroblob(700000)), '00', char(1)) AS value".into(),
10899                    parameters: Vec::new(),
10900                },
10901                ReadConsistency::Local,
10902                1,
10903            )
10904            .unwrap();
10905
10906        assert!(serde_json::to_vec(&response).unwrap().len() > MAX_SQL_RESPONSE_BYTES);
10907    }
10908
10909    #[test]
10910    fn sql_http_response_rejects_encoded_body_over_limit() {
10911        let response = SqlQueryResponse {
10912            columns: vec!["value".into()],
10913            rows: vec![vec![SqlValue::Text("\u{1}".repeat(700_000))]],
10914            applied_index: 0,
10915            hash: LogHash::ZERO,
10916        };
10917
10918        assert_eq!(
10919            sql_query_http_response(response).status(),
10920            axum::http::StatusCode::BAD_REQUEST
10921        );
10922    }
10923
10924    #[cfg(feature = "graph")]
10925    #[test]
10926    fn graph_response_work_holds_client_capacity_until_completion() {
10927        let slots = std::sync::Arc::new(tokio::sync::Semaphore::new(1));
10928        let permit = std::sync::Arc::new(slots.clone().try_acquire_owned().unwrap());
10929
10930        let capacity_exhausted_during_response =
10931            with_graph_client_permit(permit, || slots.clone().try_acquire_owned().is_err());
10932
10933        assert!(capacity_exhausted_during_response);
10934        assert!(slots.try_acquire().is_ok());
10935    }
10936
10937    #[cfg(feature = "graph")]
10938    #[test]
10939    fn graph_client_query_error_returns_400_without_latching_readiness() {
10940        let (_dir, runtime) = graph_test_runtime();
10941
10942        let error = runtime.map_graph_read_error(rhiza_graph::Error::InvalidCommand(
10943            "unknown property".into(),
10944        ));
10945        let response = node_error_response(error);
10946
10947        assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
10948        assert!(runtime.is_ready());
10949        assert!(!runtime.is_fatal());
10950    }
10951
10952    #[cfg(feature = "graph")]
10953    #[test]
10954    fn graph_resource_exhaustion_returns_503_without_latching_readiness() {
10955        let (_dir, runtime) = graph_test_runtime();
10956
10957        let error = runtime.map_graph_read_error(rhiza_graph::Error::ResourceExhausted(
10958            "buffer pool is full".into(),
10959        ));
10960        let response = node_error_response(error);
10961
10962        assert_eq!(
10963            response.status(),
10964            axum::http::StatusCode::SERVICE_UNAVAILABLE
10965        );
10966        assert!(runtime.is_ready());
10967        assert!(!runtime.is_fatal());
10968    }
10969
10970    #[cfg(feature = "kv")]
10971    #[test]
10972    fn kv_resource_exhaustion_returns_503_without_latching_readiness() {
10973        let (_dir, runtime) = kv_test_runtime();
10974
10975        let error = runtime.map_kv_read_error(rhiza_kv::Error::ResourceExhausted(
10976            "scan result is too large".into(),
10977        ));
10978        let response = node_error_response(error);
10979
10980        assert_eq!(
10981            response.status(),
10982            axum::http::StatusCode::SERVICE_UNAVAILABLE
10983        );
10984        assert!(runtime.is_ready());
10985        assert!(!runtime.is_fatal());
10986    }
10987
10988    #[cfg(feature = "graph")]
10989    #[test]
10990    fn graph_batch_coalesces_exact_retry_and_isolates_conflicting_duplicate() {
10991        let (_dir, runtime) = graph_test_runtime();
10992        let canonical =
10993            GraphCommandV1::put_document("same", "document", GraphValueV1::String("first".into()))
10994                .unwrap();
10995        let conflict = GraphCommandV1::put_document(
10996            "same",
10997            "document",
10998            GraphValueV1::String("conflict".into()),
10999        )
11000        .unwrap();
11001        let unrelated =
11002            GraphCommandV1::put_document("other", "other", GraphValueV1::U64(2)).unwrap();
11003        let results = runtime
11004            .mutate_graph_batch(vec![canonical.clone(), canonical, conflict, unrelated])
11005            .unwrap();
11006
11007        let canonical = results[0].as_ref().unwrap().applied_index();
11008        assert_eq!(results[1].as_ref().unwrap().applied_index(), canonical);
11009        assert!(matches!(
11010            results[2],
11011            Err(super::NodeError::InvalidRequest(_))
11012        ));
11013        assert_eq!(results[3].as_ref().unwrap().applied_index(), canonical);
11014        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11015        assert!(runtime.is_ready());
11016    }
11017
11018    #[cfg(feature = "kv")]
11019    #[test]
11020    fn kv_batch_coalesces_exact_retry_and_isolates_conflicting_duplicate() {
11021        let (_dir, runtime) = kv_test_runtime();
11022        let canonical = KvCommandV1::put("same", b"key".to_vec(), b"first".to_vec()).unwrap();
11023        let conflict = KvCommandV1::put("same", b"key".to_vec(), b"conflict".to_vec()).unwrap();
11024        let unrelated = KvCommandV1::put("other", b"other".to_vec(), b"second".to_vec()).unwrap();
11025        let results = runtime
11026            .mutate_kv_batch(vec![canonical.clone(), canonical, conflict, unrelated])
11027            .unwrap();
11028
11029        let canonical = results[0].as_ref().unwrap().applied_index();
11030        assert_eq!(results[1].as_ref().unwrap().applied_index(), canonical);
11031        assert!(matches!(
11032            results[2],
11033            Err(super::NodeError::InvalidRequest(_))
11034        ));
11035        assert_eq!(results[3].as_ref().unwrap().applied_index(), canonical);
11036        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11037        assert!(runtime.is_ready());
11038    }
11039
11040    #[cfg(feature = "kv")]
11041    #[test]
11042    fn kv_group_commit_coalesces_four_waiting_64_member_calls_into_one_qlog() {
11043        let (_dir, runtime) = kv_test_runtime();
11044        let runtime = Arc::new(runtime);
11045        let commit = runtime.lock_commit().unwrap();
11046        let start = Arc::new(Barrier::new(5));
11047        let workers = (0..4)
11048            .map(|call| {
11049                let runtime = Arc::clone(&runtime);
11050                let start = Arc::clone(&start);
11051                std::thread::spawn(move || {
11052                    let commands = (0..64)
11053                        .map(|member| {
11054                            let id = call * 64 + member;
11055                            KvCommandV1::put(
11056                                format!("kv-group-{id}"),
11057                                format!("key-{id}").into_bytes(),
11058                                vec![u8::try_from(call).unwrap(); 128],
11059                            )
11060                            .unwrap()
11061                        })
11062                        .collect();
11063                    start.wait();
11064                    runtime.mutate_kv_batch(commands)
11065                })
11066            })
11067            .collect::<Vec<_>>();
11068        start.wait();
11069        runtime
11070            .kv_group_commit
11071            .wait_for_pending_calls(4, Duration::from_secs(5));
11072        drop(commit);
11073
11074        let responses = workers
11075            .into_iter()
11076            .map(|worker| worker.join().unwrap().unwrap())
11077            .collect::<Vec<_>>();
11078        let anchors = responses
11079            .iter()
11080            .flatten()
11081            .map(|result| {
11082                let outcome = result.as_ref().unwrap();
11083                (outcome.applied_index(), outcome.hash())
11084            })
11085            .collect::<std::collections::HashSet<_>>();
11086        assert_eq!(anchors.len(), 1);
11087        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11088    }
11089
11090    #[cfg(feature = "kv")]
11091    #[test]
11092    fn kv_group_commit_rejects_public_257_member_call_before_writing() {
11093        let (_dir, runtime) = kv_test_runtime();
11094        let commands = (0..257)
11095            .map(|id| {
11096                KvCommandV1::put(
11097                    format!("kv-over-{id}"),
11098                    format!("key-{id}").into_bytes(),
11099                    b"value".to_vec(),
11100                )
11101                .unwrap()
11102            })
11103            .collect();
11104
11105        let error = runtime.mutate_kv_batch(commands).unwrap_err();
11106
11107        assert!(matches!(error, NodeError::InvalidRequest(_)));
11108        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11109        assert!(runtime
11110            .kv_group_commit
11111            .state
11112            .lock()
11113            .unwrap()
11114            .pending
11115            .is_empty());
11116    }
11117
11118    #[cfg(feature = "kv")]
11119    #[test]
11120    fn kv_group_commit_lone_call_completes_and_leaves_queue_idle() {
11121        let (_dir, runtime) = kv_test_runtime();
11122
11123        let outcome = runtime
11124            .mutate_kv(KvCommandV1::put("kv-lone", b"key".to_vec(), b"value".to_vec()).unwrap())
11125            .unwrap();
11126
11127        assert_eq!(outcome.applied_index(), 1);
11128        let state = runtime
11129            .kv_group_commit
11130            .state
11131            .lock()
11132            .unwrap_or_else(std::sync::PoisonError::into_inner);
11133        assert!(state.pending.is_empty());
11134        assert_eq!(state.pending_encoded_bytes, 0);
11135        assert!(!state.leader_active);
11136    }
11137
11138    #[cfg(feature = "kv")]
11139    #[test]
11140    fn kv_group_commit_shutdown_wakes_waiters_without_writing() {
11141        let (_dir, runtime) = kv_test_runtime();
11142        let runtime = Arc::new(runtime);
11143        let commit = runtime.lock_commit().unwrap();
11144        let start = Arc::new(Barrier::new(3));
11145        let workers = (0..2)
11146            .map(|id| {
11147                let runtime = Arc::clone(&runtime);
11148                let start = Arc::clone(&start);
11149                std::thread::spawn(move || {
11150                    start.wait();
11151                    runtime.mutate_kv(
11152                        KvCommandV1::put(
11153                            format!("kv-shutdown-{id}"),
11154                            format!("key-{id}").into_bytes(),
11155                            b"value".to_vec(),
11156                        )
11157                        .unwrap(),
11158                    )
11159                })
11160            })
11161            .collect::<Vec<_>>();
11162        start.wait();
11163        runtime
11164            .kv_group_commit
11165            .wait_for_pending_calls(2, Duration::from_secs(5));
11166
11167        runtime.cancel_operations();
11168        drop(commit);
11169
11170        for worker in workers {
11171            assert!(matches!(
11172                worker.join().unwrap(),
11173                Err(NodeError::Unavailable(_))
11174            ));
11175        }
11176        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11177        let state = runtime
11178            .kv_group_commit
11179            .state
11180            .lock()
11181            .unwrap_or_else(std::sync::PoisonError::into_inner);
11182        assert!(state.pending.is_empty());
11183        assert_eq!(state.pending_encoded_bytes, 0);
11184        assert!(!state.leader_active);
11185    }
11186
11187    #[cfg(feature = "kv")]
11188    #[test]
11189    fn kv_group_commit_preserves_cross_call_retry_conflict_and_new_result_offsets() {
11190        let (_dir, runtime) = kv_test_runtime();
11191        let runtime = Arc::new(runtime);
11192        let stored = KvCommandV1::put(
11193            "kv-stored",
11194            b"stored-key".to_vec(),
11195            b"stored-value".to_vec(),
11196        )
11197        .unwrap();
11198        let stored_outcome = runtime.mutate_kv(stored.clone()).unwrap();
11199        let conflict =
11200            KvCommandV1::put("kv-stored", b"stored-key".to_vec(), b"conflict".to_vec()).unwrap();
11201        let commit = runtime.lock_commit().unwrap();
11202        let start = Arc::new(Barrier::new(3));
11203        let retry_worker = {
11204            let runtime = Arc::clone(&runtime);
11205            let start = Arc::clone(&start);
11206            std::thread::spawn(move || {
11207                start.wait();
11208                runtime.mutate_kv_batch(vec![stored, conflict])
11209            })
11210        };
11211        let new_worker = {
11212            let runtime = Arc::clone(&runtime);
11213            let start = Arc::clone(&start);
11214            std::thread::spawn(move || {
11215                start.wait();
11216                runtime.mutate_kv_batch(vec![
11217                    KvCommandV1::put("kv-new-1", b"new-1".to_vec(), b"one".to_vec()).unwrap(),
11218                    KvCommandV1::put("kv-new-2", b"new-2".to_vec(), b"two".to_vec()).unwrap(),
11219                ])
11220            })
11221        };
11222        start.wait();
11223        runtime
11224            .kv_group_commit
11225            .wait_for_pending_calls(2, Duration::from_secs(5));
11226        drop(commit);
11227
11228        let retry_results = retry_worker.join().unwrap().unwrap();
11229        let new_results = new_worker.join().unwrap().unwrap();
11230
11231        assert_eq!(
11232            retry_results[0].as_ref().unwrap().applied_index(),
11233            stored_outcome.applied_index()
11234        );
11235        assert!(matches!(
11236            retry_results[1],
11237            Err(NodeError::InvalidRequest(_))
11238        ));
11239        let new_anchors = new_results
11240            .iter()
11241            .map(|result| {
11242                let outcome = result.as_ref().unwrap();
11243                (outcome.applied_index(), outcome.hash())
11244            })
11245            .collect::<std::collections::HashSet<_>>();
11246        assert_eq!(new_anchors.len(), 1);
11247        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11248    }
11249
11250    #[cfg(feature = "kv")]
11251    #[test]
11252    fn kv_group_commit_releases_pending_byte_budget_after_drain_and_failure() {
11253        let queue = super::KvGroupCommitQueue::new();
11254        let cancelled = AtomicBool::new(false);
11255        let member = |id: usize, bytes: usize| super::RuntimeBatchMember {
11256            #[cfg(feature = "sql")]
11257            request_id: format!("kv-byte-{id}"),
11258            payload: vec![u8::try_from(id).unwrap_or_default(); bytes],
11259            operation: super::QueuedOperation::Kv(
11260                KvCommandV1::put(
11261                    format!("kv-byte-{id}"),
11262                    format!("key-{id}").into_bytes(),
11263                    b"value".to_vec(),
11264                )
11265                .unwrap(),
11266            ),
11267        };
11268        for id in 0..63 {
11269            queue
11270                .enqueue(vec![member(id, MAX_COMMAND_BYTES)], &cancelled)
11271                .unwrap();
11272        }
11273
11274        let overflow = match queue.enqueue(vec![member(63, MAX_COMMAND_BYTES * 2)], &cancelled) {
11275            Ok(_) => panic!("pending KV byte budget must reject oversized aggregate work"),
11276            Err(error) => error,
11277        };
11278        assert!(matches!(overflow, NodeError::ResourceExhausted(_)));
11279
11280        let drained = queue.drain_next_group().unwrap();
11281        assert_eq!(drained.len(), 63);
11282        let released = queue
11283            .enqueue(vec![member(64, MAX_COMMAND_BYTES)], &cancelled)
11284            .unwrap()
11285            .0;
11286        queue.fail_pending(NodeError::Unavailable("test failure".into()));
11287        assert!(matches!(
11288            released.wait(&cancelled),
11289            Err(NodeError::Unavailable(_))
11290        ));
11291        let state = queue
11292            .state
11293            .lock()
11294            .unwrap_or_else(std::sync::PoisonError::into_inner);
11295        assert!(state.pending.is_empty());
11296        assert_eq!(state.pending_encoded_bytes, 0);
11297        assert!(!state.leader_active);
11298    }
11299
11300    #[cfg(feature = "kv")]
11301    #[test]
11302    fn kv_group_commit_window_restarts_after_staggered_enqueue() {
11303        let queue = Arc::new(super::KvGroupCommitQueue::new());
11304        let cancelled = AtomicBool::new(false);
11305        let member = |id: usize| super::RuntimeBatchMember {
11306            #[cfg(feature = "sql")]
11307            request_id: format!("kv-debounce-{id}"),
11308            payload: vec![u8::try_from(id).unwrap_or_default()],
11309            operation: super::QueuedOperation::Kv(
11310                KvCommandV1::put(
11311                    format!("kv-debounce-{id}"),
11312                    format!("key-{id}").into_bytes(),
11313                    b"value".to_vec(),
11314                )
11315                .unwrap(),
11316            ),
11317        };
11318        queue.enqueue(vec![member(1)], &cancelled).unwrap();
11319        let collector = Arc::clone(&queue);
11320        let (finished, receive) = std::sync::mpsc::channel();
11321        let worker = std::thread::spawn(move || {
11322            let collected = collector.collect_until_full_or_timeout(Duration::from_millis(100));
11323            finished.send(collected).unwrap();
11324        });
11325
11326        std::thread::sleep(Duration::from_millis(75));
11327        queue.enqueue(vec![member(2)], &cancelled).unwrap();
11328        std::thread::sleep(Duration::from_millis(75));
11329        queue.enqueue(vec![member(3)], &cancelled).unwrap();
11330        assert!(receive.recv_timeout(Duration::from_millis(50)).is_err());
11331        assert!(receive.recv_timeout(Duration::from_millis(150)).unwrap());
11332        worker.join().unwrap();
11333        assert_eq!(queue.drain_next_group().unwrap().len(), 3);
11334    }
11335
11336    #[cfg(feature = "kv")]
11337    #[test]
11338    fn kv_group_commit_returns_committed_group_when_cancelled_after_execution() {
11339        let (_dir, mut runtime) = kv_test_runtime();
11340        runtime.kv_group_commit_after_execute_hook = Some(Arc::new(NodeRuntime::cancel_operations));
11341        let runtime = Arc::new(runtime);
11342        let commit = runtime.lock_commit().unwrap();
11343        let start = Arc::new(Barrier::new(3));
11344        let workers = (0..2)
11345            .map(|call| {
11346                let runtime = Arc::clone(&runtime);
11347                let start = Arc::clone(&start);
11348                std::thread::spawn(move || {
11349                    let commands = (0..64)
11350                        .map(|member| {
11351                            let id = call * 64 + member;
11352                            KvCommandV1::put(
11353                                format!("kv-cancel-after-{id}"),
11354                                format!("key-{id}").into_bytes(),
11355                                b"value".to_vec(),
11356                            )
11357                            .unwrap()
11358                        })
11359                        .collect();
11360                    start.wait();
11361                    runtime.mutate_kv_batch(commands)
11362                })
11363            })
11364            .collect::<Vec<_>>();
11365        start.wait();
11366        runtime
11367            .kv_group_commit
11368            .wait_for_pending_calls(2, Duration::from_secs(5));
11369        drop(commit);
11370
11371        let results = workers
11372            .into_iter()
11373            .flat_map(|worker| worker.join().unwrap().unwrap())
11374            .collect::<Vec<_>>();
11375
11376        assert_eq!(results.len(), 128);
11377        let anchors = results
11378            .iter()
11379            .map(|result| {
11380                let outcome = result.as_ref().unwrap();
11381                (outcome.applied_index(), outcome.hash())
11382            })
11383            .collect::<std::collections::HashSet<_>>();
11384        assert_eq!(anchors.len(), 1);
11385        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11386        assert!(runtime.operation_cancelled.load(Ordering::Acquire));
11387    }
11388
11389    #[cfg(feature = "kv")]
11390    #[test]
11391    fn kv_largest_fitting_prefix_is_exact_for_large_grouped_batch() {
11392        let commands = (0..256)
11393            .map(|id| {
11394                KvCommandV1::put(
11395                    format!("kv-prefix-{id:04}"),
11396                    format!("key-{id:04}").into_bytes(),
11397                    vec![b'x'; 4 * 1024],
11398                )
11399                .unwrap()
11400            })
11401            .collect::<Vec<_>>();
11402        assert!(super::encode_replicated_kv_batch(&commands).unwrap().len() > MAX_COMMAND_BYTES);
11403        let expected = (2..commands.len())
11404            .filter(|count| {
11405                super::encode_replicated_kv_batch(&commands[..*count])
11406                    .unwrap()
11407                    .len()
11408                    <= MAX_COMMAND_BYTES
11409            })
11410            .max()
11411            .unwrap();
11412
11413        let (count, payload) = super::largest_fitting_kv_batch_prefix(&commands).unwrap();
11414
11415        assert_eq!(count, expected);
11416        assert!(payload.len() <= MAX_COMMAND_BYTES);
11417        assert!(
11418            super::encode_replicated_kv_batch(&commands[..count + 1])
11419                .unwrap()
11420                .len()
11421                > MAX_COMMAND_BYTES
11422        );
11423    }
11424
11425    #[cfg(feature = "kv")]
11426    #[test]
11427    fn kv_group_commit_large_batch_uses_largest_fitting_fifo_sub_batches() {
11428        let (_dir, runtime) = kv_test_runtime();
11429        let runtime = Arc::new(runtime);
11430        let calls = (0..4)
11431            .map(|call| {
11432                (0..64)
11433                    .map(|member| {
11434                        let id = call * 64 + member;
11435                        KvCommandV1::put(
11436                            format!("kv-large-{id:04}"),
11437                            format!("key-{id:04}").into_bytes(),
11438                            vec![b'x'; 4 * 1024],
11439                        )
11440                        .unwrap()
11441                    })
11442                    .collect::<Vec<_>>()
11443            })
11444            .collect::<Vec<_>>();
11445        let flattened = calls.iter().flatten().cloned().collect::<Vec<_>>();
11446        let (largest_prefix, _) = super::largest_fitting_kv_batch_prefix(&flattened).unwrap();
11447        let expected_entries = flattened.len().div_ceil(largest_prefix);
11448        let commit = runtime.lock_commit().unwrap();
11449        let start = Arc::new(Barrier::new(5));
11450        let workers = calls
11451            .into_iter()
11452            .map(|commands| {
11453                let runtime = Arc::clone(&runtime);
11454                let start = Arc::clone(&start);
11455                std::thread::spawn(move || {
11456                    start.wait();
11457                    runtime.mutate_kv_batch(commands)
11458                })
11459            })
11460            .collect::<Vec<_>>();
11461        start.wait();
11462        runtime
11463            .kv_group_commit
11464            .wait_for_pending_calls(4, Duration::from_secs(5));
11465        drop(commit);
11466
11467        let mut counts = std::collections::BTreeMap::new();
11468        for result in workers
11469            .into_iter()
11470            .flat_map(|worker| worker.join().unwrap().unwrap())
11471        {
11472            *counts
11473                .entry(result.unwrap().applied_index())
11474                .or_insert(0_usize) += 1;
11475        }
11476
11477        assert_eq!(counts.len(), expected_entries);
11478        let counts = counts.into_values().collect::<Vec<_>>();
11479        assert!(counts[..counts.len() - 1]
11480            .iter()
11481            .all(|count| *count == largest_prefix));
11482        assert_eq!(counts.iter().sum::<usize>(), 256);
11483        assert_eq!(
11484            runtime.log_store().last_index().unwrap(),
11485            Some(u64::try_from(expected_entries).unwrap())
11486        );
11487        for index in 1..=u64::try_from(expected_entries).unwrap() {
11488            assert!(runtime
11489                .log_store()
11490                .read(index)
11491                .unwrap()
11492                .is_some_and(|entry| entry.payload.len() <= MAX_COMMAND_BYTES));
11493        }
11494    }
11495
11496    #[cfg(feature = "kv")]
11497    #[test]
11498    fn kv_group_commit_leader_panic_wakes_active_and_pending_calls_with_same_fatal() {
11499        let (_dir, mut runtime) = kv_test_runtime();
11500        runtime.kv_group_commit_before_execute_hook =
11501            Some(Arc::new(|| panic!("injected KV group leader panic")));
11502        let runtime = Arc::new(runtime);
11503        let commit = runtime.lock_commit().unwrap();
11504        let mut workers = Vec::new();
11505        for call in 0..17 {
11506            let worker_runtime = Arc::clone(&runtime);
11507            workers.push(std::thread::spawn(move || {
11508                worker_runtime.mutate_kv_batch(
11509                    (0..64)
11510                        .map(|member| {
11511                            let id = call * 64 + member;
11512                            KvCommandV1::put(
11513                                format!("kv-panic-{id}"),
11514                                format!("key-{id}").into_bytes(),
11515                                b"value".to_vec(),
11516                            )
11517                            .unwrap()
11518                        })
11519                        .collect(),
11520                )
11521            }));
11522            runtime
11523                .kv_group_commit
11524                .wait_for_pending_calls(call + 1, Duration::from_secs(5));
11525        }
11526        drop(commit);
11527
11528        let errors = workers
11529            .into_iter()
11530            .map(|worker| worker.join().unwrap().unwrap_err())
11531            .collect::<Vec<_>>();
11532        assert!(errors
11533            .iter()
11534            .all(|error| matches!(error, NodeError::Fatal(_))));
11535        assert_eq!(errors[0].to_string(), errors[1].to_string());
11536        assert!(runtime.is_fatal());
11537        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11538    }
11539
11540    #[cfg(feature = "kv")]
11541    #[test]
11542    fn kv_group_commit_reopens_all_four_grouped_calls_at_shared_durable_anchor() {
11543        let (dir, runtime) = kv_test_runtime();
11544        let runtime = Arc::new(runtime);
11545        let commit = runtime.lock_commit().unwrap();
11546        let start = Arc::new(Barrier::new(5));
11547        let workers = (0..4)
11548            .map(|call| {
11549                let runtime = Arc::clone(&runtime);
11550                let start = Arc::clone(&start);
11551                std::thread::spawn(move || {
11552                    let commands = (0..64)
11553                        .map(|member| {
11554                            let id = call * 64 + member;
11555                            KvCommandV1::put(
11556                                format!("kv-reopen-{id}"),
11557                                format!("key-{id:04}").into_bytes(),
11558                                vec![u8::try_from(call).unwrap(); 128],
11559                            )
11560                            .unwrap()
11561                        })
11562                        .collect();
11563                    start.wait();
11564                    runtime.mutate_kv_batch(commands)
11565                })
11566            })
11567            .collect::<Vec<_>>();
11568        start.wait();
11569        runtime
11570            .kv_group_commit
11571            .wait_for_pending_calls(4, Duration::from_secs(5));
11572        drop(commit);
11573
11574        let results = workers
11575            .into_iter()
11576            .flat_map(|worker| worker.join().unwrap().unwrap())
11577            .collect::<Vec<_>>();
11578        let anchors = results
11579            .iter()
11580            .map(|result| {
11581                let outcome = result.as_ref().unwrap();
11582                (outcome.applied_index(), outcome.hash())
11583            })
11584            .collect::<std::collections::HashSet<_>>();
11585        let [(applied_index, applied_hash)] = anchors.into_iter().collect::<Vec<_>>()[..] else {
11586            panic!("four grouped calls must share one durable anchor");
11587        };
11588        assert_eq!(
11589            runtime.log_store().last_index().unwrap(),
11590            Some(applied_index)
11591        );
11592        let config = runtime.config().clone();
11593        drop(runtime);
11594
11595        let consensus = Arc::new(
11596            ThreeNodeConsensus::from_recovered_tip(
11597                config.cluster_id().to_owned(),
11598                config.node_id().to_owned(),
11599                config.epoch(),
11600                config.config_id(),
11601                [
11602                    dir.path().join("recorders/n1"),
11603                    dir.path().join("recorders/n2"),
11604                    dir.path().join("recorders/n3"),
11605                ],
11606                applied_index + 1,
11607                applied_hash,
11608            )
11609            .unwrap(),
11610        );
11611        let reopened = NodeRuntime::open(config, consensus, &[]).unwrap();
11612
11613        assert_eq!(reopened.applied_index().unwrap(), applied_index);
11614        assert_eq!(reopened.applied_hash().unwrap(), applied_hash);
11615        assert_eq!(reopened.log_store().last_index().unwrap(), Some(1));
11616        for id in 0..256 {
11617            let response = reopened
11618                .get_kv(format!("key-{id:04}").as_bytes(), ReadConsistency::Local)
11619                .unwrap();
11620            assert_eq!(
11621                response.value,
11622                Some(vec![u8::try_from(id / 64).unwrap(); 128])
11623            );
11624            assert_eq!(response.applied_index, applied_index);
11625            assert_eq!(response.hash, applied_hash);
11626        }
11627    }
11628
11629    #[test]
11630    fn sql_batch_preflight_rejects_entire_vector_without_growing_log() {
11631        let (_dir, runtime) = sql_test_runtime();
11632        let valid = SqlCommand {
11633            request_id: "valid".into(),
11634            statements: vec![SqlStatement {
11635                sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY)".into(),
11636                parameters: vec![],
11637            }],
11638        };
11639        let invalid = SqlCommand {
11640            request_id: String::new(),
11641            statements: valid.statements.clone(),
11642        };
11643
11644        let error = runtime.execute_sql_batch(vec![valid, invalid]).unwrap_err();
11645
11646        assert!(matches!(error, NodeError::InvalidRequest(_)));
11647        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11648    }
11649
11650    #[test]
11651    fn sql_batch_rejects_aggregate_encoded_input_over_command_cap_before_io() {
11652        let (_dir, runtime) = sql_test_runtime();
11653        let command = |request_id: &str, fill: char| SqlCommand {
11654            request_id: request_id.into(),
11655            statements: vec![SqlStatement {
11656                sql: "SELECT ?1".into(),
11657                parameters: vec![SqlValue::Text(
11658                    std::iter::repeat_n(fill, MAX_COMMAND_BYTES / 2).collect(),
11659                )],
11660            }],
11661        };
11662
11663        let error = runtime
11664            .execute_sql_batch(vec![
11665                command("aggregate-a", 'a'),
11666                command("aggregate-b", 'b'),
11667            ])
11668            .unwrap_err();
11669
11670        assert!(matches!(error, NodeError::ResourceExhausted(_)));
11671        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11672        assert!(runtime
11673            .sql_group_commit
11674            .state
11675            .lock()
11676            .unwrap()
11677            .pending
11678            .is_empty());
11679    }
11680
11681    #[test]
11682    fn sql_write_profiling_records_nothing_when_observer_is_not_installed() {
11683        let profiler = SqlWriteProfiler::new(8);
11684        let (_dir, runtime) = sql_test_runtime();
11685
11686        runtime
11687            .execute_sql(SqlCommand {
11688                request_id: "schema".into(),
11689                statements: vec![SqlStatement {
11690                    sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11691                    parameters: vec![],
11692                }],
11693            })
11694            .unwrap();
11695
11696        assert!(runtime.config().sql_write_profiler().is_none());
11697        assert!(profiler.snapshot().samples.is_empty());
11698    }
11699
11700    #[test]
11701    fn sql_write_profiling_records_one_consistent_sample_for_one_physical_batch() {
11702        let profiler = SqlWriteProfiler::new(8);
11703        let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11704        runtime
11705            .execute_sql(SqlCommand {
11706                request_id: "schema".into(),
11707                statements: vec![SqlStatement {
11708                    sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11709                    parameters: vec![],
11710                }],
11711            })
11712            .unwrap();
11713        profiler.drain();
11714
11715        let commands = (1..=3)
11716            .map(|id| SqlCommand {
11717                request_id: format!("insert-{id}"),
11718                statements: vec![SqlStatement {
11719                    sql: "INSERT INTO profiled_items(id) VALUES (?1)".into(),
11720                    parameters: vec![SqlValue::Integer(id)],
11721                }],
11722            })
11723            .collect();
11724        let responses = runtime.execute_sql_batch(commands).unwrap();
11725
11726        let snapshot = profiler.snapshot();
11727        assert_eq!(snapshot.dropped_samples, 0);
11728        let [sample] = snapshot.samples.as_slice() else {
11729            panic!("one physical SQL batch must emit one sample: {snapshot:?}");
11730        };
11731        assert_eq!(sample.batch_member_count, 3);
11732        assert_eq!(
11733            sample.total_service_us,
11734            sample
11735                .commit_lock_wait_us
11736                .saturating_add(sample.precheck_classification_us)
11737                .saturating_add(sample.qwal_prepare_us)
11738                .saturating_add(sample.consensus_propose_us)
11739                .saturating_add(sample.local_qlog_mirror_append_us)
11740                .saturating_add(sample.sql_materializer_apply_us)
11741                .saturating_add(sample.response_other_total_us)
11742        );
11743        let (applied_index, applied_hash) = runtime.ensure_materialized_tip().unwrap();
11744        assert!(responses.iter().all(|response| {
11745            response.as_ref().is_ok_and(|response| {
11746                response.applied_index == applied_index && response.hash == applied_hash
11747            })
11748        }));
11749    }
11750
11751    #[test]
11752    fn sql_write_profiling_does_not_fabricate_sample_for_failed_batch() {
11753        let profiler = SqlWriteProfiler::new(8);
11754        let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11755        runtime
11756            .execute_sql(SqlCommand {
11757                request_id: "schema".into(),
11758                statements: vec![SqlStatement {
11759                    sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11760                    parameters: vec![],
11761                }],
11762            })
11763            .unwrap();
11764        runtime
11765            .execute_sql(SqlCommand {
11766                request_id: "first".into(),
11767                statements: vec![SqlStatement {
11768                    sql: "INSERT INTO profiled_items(id) VALUES (1)".into(),
11769                    parameters: vec![],
11770                }],
11771            })
11772            .unwrap();
11773        profiler.drain();
11774        let last_index = runtime.log_store().last_index().unwrap();
11775
11776        let error = runtime
11777            .execute_sql(SqlCommand {
11778                request_id: "duplicate".into(),
11779                statements: vec![SqlStatement {
11780                    sql: "INSERT INTO profiled_items(id) VALUES (1)".into(),
11781                    parameters: vec![],
11782                }],
11783            })
11784            .unwrap_err();
11785
11786        assert!(matches!(error, NodeError::InvalidSqlStatement { .. }));
11787        assert!(profiler.snapshot().samples.is_empty());
11788        assert_eq!(runtime.log_store().last_index().unwrap(), last_index);
11789    }
11790
11791    #[test]
11792    fn sql_group_commit_coalesces_four_waiting_typed_calls_into_one_1024_receipt_qwal() {
11793        let profiler = SqlWriteProfiler::new(8);
11794        let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11795        let runtime = Arc::new(runtime);
11796        runtime
11797            .execute_sql(SqlCommand {
11798                request_id: "group-schema".into(),
11799                statements: vec![SqlStatement {
11800                    sql: "CREATE TABLE grouped_items(id INTEGER PRIMARY KEY)".into(),
11801                    parameters: vec![],
11802                }],
11803            })
11804            .unwrap();
11805        profiler.drain();
11806
11807        let commit = runtime.lock_commit().unwrap();
11808        let start = Arc::new(Barrier::new(5));
11809        let workers = (0..4)
11810            .map(|call| {
11811                let runtime = Arc::clone(&runtime);
11812                let start = Arc::clone(&start);
11813                std::thread::spawn(move || {
11814                    let commands = (0..256)
11815                        .map(|offset| {
11816                            let id = call * 256 + offset;
11817                            SqlCommand {
11818                                request_id: format!("group-{id}"),
11819                                statements: vec![SqlStatement {
11820                                    sql: "INSERT INTO grouped_items(id) VALUES (?1)".into(),
11821                                    parameters: vec![SqlValue::Integer(id)],
11822                                }],
11823                            }
11824                        })
11825                        .collect();
11826                    start.wait();
11827                    runtime.execute_sql_batch(commands).unwrap()
11828                })
11829            })
11830            .collect::<Vec<_>>();
11831        start.wait();
11832        runtime
11833            .sql_group_commit
11834            .wait_for_pending_calls(4, Duration::from_secs(5));
11835        drop(commit);
11836
11837        let results = workers
11838            .into_iter()
11839            .flat_map(|worker| worker.join().unwrap())
11840            .collect::<Vec<_>>();
11841        assert_eq!(results.len(), 1024);
11842        let first = results[0].as_ref().unwrap();
11843        assert!(results.iter().all(|result| {
11844            result.as_ref().is_ok_and(|result| {
11845                result.applied_index == first.applied_index && result.hash == first.hash
11846            })
11847        }));
11848        let entry = runtime
11849            .log_store()
11850            .read(first.applied_index)
11851            .unwrap()
11852            .unwrap();
11853        assert_eq!(
11854            rhiza_sql::decode_qwal_v3(&entry.payload)
11855                .unwrap()
11856                .receipts
11857                .len(),
11858            1024
11859        );
11860        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11861        let snapshot = profiler.snapshot();
11862        let [sample] = snapshot.samples.as_slice() else {
11863            panic!("one grouped physical commit must emit one sample: {snapshot:?}");
11864        };
11865        assert_eq!(sample.batch_member_count, 1024);
11866    }
11867
11868    #[test]
11869    fn sql_group_commit_keeps_fifth_whole_call_for_the_next_physical_group() {
11870        let (_dir, runtime) = sql_test_runtime();
11871        let runtime = Arc::new(runtime);
11872        runtime
11873            .execute_sql(SqlCommand {
11874                request_id: "next-group-schema".into(),
11875                statements: vec![SqlStatement {
11876                    sql: "CREATE TABLE next_group_items(id INTEGER PRIMARY KEY)".into(),
11877                    parameters: vec![],
11878                }],
11879            })
11880            .unwrap();
11881
11882        let commit = runtime.lock_commit().unwrap();
11883        let mut workers = Vec::new();
11884        for call in 0..5 {
11885            let worker_runtime = Arc::clone(&runtime);
11886            workers.push(std::thread::spawn(move || {
11887                worker_runtime
11888                    .execute_sql_batch(
11889                        (0..256)
11890                            .map(|offset| {
11891                                let id = call * 256 + offset;
11892                                SqlCommand {
11893                                    request_id: format!("next-group-{id}"),
11894                                    statements: vec![SqlStatement {
11895                                        sql: "INSERT INTO next_group_items(id) VALUES (?1)".into(),
11896                                        parameters: vec![SqlValue::Integer(id)],
11897                                    }],
11898                                }
11899                            })
11900                            .collect(),
11901                    )
11902                    .unwrap()
11903            }));
11904            runtime
11905                .sql_group_commit
11906                .wait_for_pending_calls(call as usize + 1, Duration::from_secs(5));
11907        }
11908        drop(commit);
11909
11910        let calls = workers
11911            .into_iter()
11912            .map(|worker| worker.join().unwrap())
11913            .collect::<Vec<_>>();
11914        for call in &calls[..4] {
11915            assert!(call
11916                .iter()
11917                .all(|result| result.as_ref().unwrap().applied_index == 2));
11918        }
11919        assert!(calls[4]
11920            .iter()
11921            .all(|result| result.as_ref().unwrap().applied_index == 3));
11922        assert_eq!(
11923            rhiza_sql::decode_qwal_v3(&runtime.log_store().read(2).unwrap().unwrap().payload)
11924                .unwrap()
11925                .receipts
11926                .len(),
11927            1024
11928        );
11929        assert_eq!(
11930            rhiza_sql::decode_qwal_v3(&runtime.log_store().read(3).unwrap().unwrap().payload)
11931                .unwrap()
11932                .receipts
11933                .len(),
11934            256
11935        );
11936    }
11937
11938    #[test]
11939    fn sql_group_commit_preserves_fifo_call_offsets_for_retries_conflicts_aliases_and_failures() {
11940        let (_dir, runtime) = sql_test_runtime();
11941        let runtime = Arc::new(runtime);
11942        runtime
11943            .execute_sql(SqlCommand {
11944                request_id: "fifo-schema".into(),
11945                statements: vec![SqlStatement {
11946                    sql: "CREATE TABLE fifo_items(id INTEGER PRIMARY KEY, value TEXT UNIQUE)"
11947                        .into(),
11948                    parameters: vec![],
11949                }],
11950            })
11951            .unwrap();
11952        let stored_command = SqlCommand {
11953            request_id: "fifo-stored".into(),
11954            statements: vec![SqlStatement {
11955                sql: "INSERT INTO fifo_items(id, value) VALUES (1, 'stored')".into(),
11956                parameters: vec![],
11957            }],
11958        };
11959        let stored = runtime.execute_sql(stored_command.clone()).unwrap();
11960        let valid_alias = SqlCommand {
11961            request_id: "fifo-alias".into(),
11962            statements: vec![SqlStatement {
11963                sql: "INSERT INTO fifo_items(id, value) VALUES (2, 'alias')".into(),
11964                parameters: vec![],
11965            }],
11966        };
11967        let conflict = SqlCommand {
11968            request_id: stored_command.request_id.clone(),
11969            statements: vec![SqlStatement {
11970                sql: "INSERT INTO fifo_items(id, value) VALUES (3, 'conflict')".into(),
11971                parameters: vec![],
11972            }],
11973        };
11974        let failed = SqlCommand {
11975            request_id: "fifo-failed".into(),
11976            statements: vec![SqlStatement {
11977                sql: "INSERT INTO fifo_items(id, value) VALUES (4, 'stored')".into(),
11978                parameters: vec![],
11979            }],
11980        };
11981        let valid = SqlCommand {
11982            request_id: "fifo-valid".into(),
11983            statements: vec![SqlStatement {
11984                sql: "INSERT INTO fifo_items(id, value) VALUES (5, 'valid')".into(),
11985                parameters: vec![],
11986            }],
11987        };
11988
11989        let commit = runtime.lock_commit().unwrap();
11990        let first_runtime = Arc::clone(&runtime);
11991        let first = std::thread::spawn(move || {
11992            first_runtime
11993                .execute_sql_batch(vec![
11994                    stored_command,
11995                    conflict,
11996                    valid_alias.clone(),
11997                    valid_alias,
11998                ])
11999                .unwrap()
12000        });
12001        runtime
12002            .sql_group_commit
12003            .wait_for_pending_calls(1, Duration::from_secs(5));
12004        let second_runtime = Arc::clone(&runtime);
12005        let second = std::thread::spawn(move || {
12006            second_runtime
12007                .execute_sql_batch(vec![failed, valid])
12008                .unwrap()
12009        });
12010        runtime
12011            .sql_group_commit
12012            .wait_for_pending_calls(2, Duration::from_secs(5));
12013        drop(commit);
12014
12015        let first = first.join().unwrap();
12016        let second = second.join().unwrap();
12017        assert_eq!(
12018            first[0].as_ref().unwrap().applied_index,
12019            stored.applied_index
12020        );
12021        assert!(matches!(first[1], Err(NodeError::RequestConflict(_))));
12022        assert_eq!(first[2], first[3]);
12023        assert_eq!(first[2].as_ref().unwrap().applied_index, 3);
12024        assert!(matches!(
12025            second[0],
12026            Err(NodeError::InvalidSqlStatement { .. })
12027        ));
12028        assert_eq!(second[1].as_ref().unwrap().applied_index, 3);
12029    }
12030
12031    #[test]
12032    fn sql_group_commit_rejects_overload_before_enqueue_without_orphaning_the_leader() {
12033        let (_dir, runtime) = sql_test_runtime_configured(None, Some(1));
12034        let runtime = Arc::new(runtime);
12035        runtime
12036            .execute_sql(SqlCommand {
12037                request_id: "overload-schema".into(),
12038                statements: vec![SqlStatement {
12039                    sql: "CREATE TABLE overload_items(id INTEGER PRIMARY KEY)".into(),
12040                    parameters: vec![],
12041                }],
12042            })
12043            .unwrap();
12044        let command = |request_id: &str, id| SqlCommand {
12045            request_id: request_id.into(),
12046            statements: vec![SqlStatement {
12047                sql: "INSERT INTO overload_items(id) VALUES (?1)".into(),
12048                parameters: vec![SqlValue::Integer(id)],
12049            }],
12050        };
12051
12052        let commit = runtime.lock_commit().unwrap();
12053        let leader_runtime = Arc::clone(&runtime);
12054        let leader = std::thread::spawn(move || {
12055            leader_runtime.execute_sql_batch(vec![command("overload-first", 1)])
12056        });
12057        runtime
12058            .sql_group_commit
12059            .wait_for_pending_calls(1, Duration::from_secs(5));
12060        let overload = runtime
12061            .execute_sql_batch(vec![command("overload-second", 2)])
12062            .unwrap_err();
12063        assert!(matches!(overload, NodeError::ResourceExhausted(_)));
12064        drop(commit);
12065        assert!(leader.join().unwrap().unwrap()[0].is_ok());
12066        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
12067    }
12068
12069    #[test]
12070    fn sql_group_commit_bounds_pending_bytes_and_releases_reservations() {
12071        let queue = super::SqlGroupCommitQueue::new(super::MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY);
12072        let cancelled = AtomicBool::new(false);
12073        let member = |id: usize| super::RuntimeBatchMember {
12074            request_id: format!("queued-{id}"),
12075            payload: vec![u8::try_from(id).unwrap_or_default(); MAX_COMMAND_BYTES],
12076            operation: super::QueuedOperation::Sql(SqlCommand {
12077                request_id: format!("queued-{id}"),
12078                statements: vec![SqlStatement {
12079                    sql: "SELECT 1".into(),
12080                    parameters: vec![],
12081                }],
12082            }),
12083        };
12084        let mut queued = Vec::new();
12085        for id in 0..super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY {
12086            queued.push(queue.enqueue(vec![member(id)], &cancelled).unwrap().0);
12087        }
12088
12089        let overflow = match queue.enqueue(
12090            vec![member(super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY)],
12091            &cancelled,
12092        ) {
12093            Ok(_) => panic!("pending byte budget must reject one more full command"),
12094            Err(error) => error,
12095        };
12096        assert!(matches!(overflow, NodeError::ResourceExhausted(_)));
12097
12098        let drained = queue.drain_next_group().unwrap();
12099        assert_eq!(drained.len(), 4);
12100        let released = queue
12101            .enqueue(
12102                vec![member(super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY + 1)],
12103                &cancelled,
12104            )
12105            .unwrap()
12106            .0;
12107
12108        queue.fail_pending(NodeError::Unavailable("test failure".into()));
12109        for job in queued.into_iter().skip(drained.len()) {
12110            assert!(matches!(
12111                job.wait(&cancelled),
12112                Err(NodeError::Unavailable(_))
12113            ));
12114        }
12115        assert!(matches!(
12116            released.wait(&cancelled),
12117            Err(NodeError::Unavailable(_))
12118        ));
12119        let state = queue
12120            .state
12121            .lock()
12122            .unwrap_or_else(std::sync::PoisonError::into_inner);
12123        assert!(state.pending.is_empty());
12124        assert_eq!(state.pending_encoded_bytes, 0);
12125        assert!(!state.leader_active);
12126    }
12127
12128    #[test]
12129    fn sql_group_commit_window_restarts_after_staggered_enqueue() {
12130        let queue = Arc::new(super::SqlGroupCommitQueue::new(
12131            super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY,
12132        ));
12133        let cancelled = AtomicBool::new(false);
12134        let member = |id: usize| super::RuntimeBatchMember {
12135            request_id: format!("sql-debounce-{id}"),
12136            payload: vec![u8::try_from(id).unwrap_or_default()],
12137            operation: super::QueuedOperation::Sql(SqlCommand {
12138                request_id: format!("sql-debounce-{id}"),
12139                statements: vec![SqlStatement {
12140                    sql: "SELECT 1".into(),
12141                    parameters: vec![],
12142                }],
12143            }),
12144        };
12145        queue.enqueue(vec![member(1)], &cancelled).unwrap();
12146        let collector = Arc::clone(&queue);
12147        let (finished, receive) = std::sync::mpsc::channel();
12148        let worker = std::thread::spawn(move || {
12149            let collected = collector.collect_until_full_or_timeout(Duration::from_millis(100));
12150            finished.send(collected).unwrap();
12151        });
12152
12153        std::thread::sleep(Duration::from_millis(75));
12154        queue.enqueue(vec![member(2)], &cancelled).unwrap();
12155        std::thread::sleep(Duration::from_millis(75));
12156        queue.enqueue(vec![member(3)], &cancelled).unwrap();
12157        assert!(receive.recv_timeout(Duration::from_millis(50)).is_err());
12158        assert!(receive.recv_timeout(Duration::from_millis(150)).unwrap());
12159        worker.join().unwrap();
12160        assert_eq!(queue.drain_next_group().unwrap().len(), 3);
12161    }
12162
12163    #[test]
12164    fn sql_group_commit_leader_panic_wakes_every_queued_call_with_the_same_fatal_error() {
12165        let (_dir, mut runtime) = sql_test_runtime();
12166        runtime
12167            .execute_sql(SqlCommand {
12168                request_id: "panic-schema".into(),
12169                statements: vec![SqlStatement {
12170                    sql: "CREATE TABLE panic_items(id INTEGER PRIMARY KEY)".into(),
12171                    parameters: vec![],
12172                }],
12173            })
12174            .unwrap();
12175        runtime.sql_group_commit_before_execute_hook =
12176            Some(Arc::new(|| panic!("injected SQL group leader panic")));
12177        let runtime = Arc::new(runtime);
12178        let commit = runtime.lock_commit().unwrap();
12179        let mut workers = Vec::new();
12180        for id in 1..=2 {
12181            let worker_runtime = Arc::clone(&runtime);
12182            workers.push(std::thread::spawn(move || {
12183                worker_runtime.execute_sql_batch(vec![SqlCommand {
12184                    request_id: format!("panic-{id}"),
12185                    statements: vec![SqlStatement {
12186                        sql: "INSERT INTO panic_items(id) VALUES (?1)".into(),
12187                        parameters: vec![SqlValue::Integer(id)],
12188                    }],
12189                }])
12190            }));
12191            runtime
12192                .sql_group_commit
12193                .wait_for_pending_calls(id as usize, Duration::from_secs(5));
12194        }
12195        drop(commit);
12196
12197        let errors = workers
12198            .into_iter()
12199            .map(|worker| worker.join().unwrap().unwrap_err())
12200            .collect::<Vec<_>>();
12201        assert!(errors
12202            .iter()
12203            .all(|error| matches!(error, NodeError::Fatal(_))));
12204        assert_eq!(errors[0].to_string(), errors[1].to_string());
12205        assert!(runtime.is_fatal());
12206    }
12207
12208    #[test]
12209    fn sql_group_commit_shutdown_wakes_queued_calls_with_the_same_unavailable_error() {
12210        let (_dir, runtime) = sql_test_runtime();
12211        let runtime = Arc::new(runtime);
12212        runtime
12213            .execute_sql(SqlCommand {
12214                request_id: "shutdown-schema".into(),
12215                statements: vec![SqlStatement {
12216                    sql: "CREATE TABLE shutdown_items(id INTEGER PRIMARY KEY)".into(),
12217                    parameters: vec![],
12218                }],
12219            })
12220            .unwrap();
12221        let command = |id| SqlCommand {
12222            request_id: format!("shutdown-{id}"),
12223            statements: vec![SqlStatement {
12224                sql: "INSERT INTO shutdown_items(id) VALUES (?1)".into(),
12225                parameters: vec![SqlValue::Integer(id)],
12226            }],
12227        };
12228
12229        let commit = runtime.lock_commit().unwrap();
12230        let leader_runtime = Arc::clone(&runtime);
12231        let leader = std::thread::spawn(move || leader_runtime.execute_sql_batch(vec![command(1)]));
12232        runtime
12233            .sql_group_commit
12234            .wait_for_pending_calls(1, Duration::from_secs(5));
12235        let follower_runtime = Arc::clone(&runtime);
12236        let follower =
12237            std::thread::spawn(move || follower_runtime.execute_sql_batch(vec![command(2)]));
12238        runtime
12239            .sql_group_commit
12240            .wait_for_pending_calls(2, Duration::from_secs(5));
12241
12242        runtime.cancel_operations();
12243        let follower_error = follower.join().unwrap().unwrap_err();
12244        drop(commit);
12245        let leader_error = leader.join().unwrap().unwrap_err();
12246
12247        assert!(matches!(leader_error, NodeError::Unavailable(_)));
12248        assert_eq!(leader_error.to_string(), follower_error.to_string());
12249        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
12250    }
12251
12252    #[test]
12253    fn sql_group_commit_reprepares_combined_calls_after_a_foreign_slot_winner() {
12254        let (_dir, runtime) = sql_test_runtime();
12255        let runtime = Arc::new(runtime);
12256        let schema = runtime
12257            .execute_sql(SqlCommand {
12258                request_id: "group-winner-schema".into(),
12259                statements: vec![SqlStatement {
12260                    sql: "CREATE TABLE group_winner(id INTEGER PRIMARY KEY)".into(),
12261                    parameters: vec![],
12262                }],
12263            })
12264            .unwrap();
12265        let winner = runtime
12266            .consensus()
12267            .propose_at(
12268                2,
12269                schema.hash,
12270                Command::new(CommandKind::ReadBarrier, Vec::new()),
12271            )
12272            .unwrap();
12273
12274        let commit = runtime.lock_commit().unwrap();
12275        let mut workers = Vec::new();
12276        for id in 1..=2 {
12277            let worker_runtime = Arc::clone(&runtime);
12278            workers.push(std::thread::spawn(move || {
12279                worker_runtime
12280                    .execute_sql_batch(vec![SqlCommand {
12281                        request_id: format!("group-winner-{id}"),
12282                        statements: vec![SqlStatement {
12283                            sql: "INSERT INTO group_winner(id) VALUES (?1)".into(),
12284                            parameters: vec![SqlValue::Integer(id)],
12285                        }],
12286                    }])
12287                    .unwrap()
12288            }));
12289            runtime
12290                .sql_group_commit
12291                .wait_for_pending_calls(id as usize, Duration::from_secs(5));
12292        }
12293        drop(commit);
12294
12295        let results = workers
12296            .into_iter()
12297            .flat_map(|worker| worker.join().unwrap())
12298            .collect::<Vec<_>>();
12299        assert_eq!(runtime.log_store().read(2).unwrap(), Some(winner));
12300        assert!(results
12301            .iter()
12302            .all(|result| result.as_ref().unwrap().applied_index == 3));
12303        assert_eq!(
12304            results[0].as_ref().unwrap().hash,
12305            results[1].as_ref().unwrap().hash
12306        );
12307    }
12308
12309    #[test]
12310    fn sql_group_commit_all_failed_calls_return_aligned_without_consensus() {
12311        let (_dir, runtime) = sql_test_runtime();
12312        let runtime = Arc::new(runtime);
12313        runtime
12314            .execute_sql(SqlCommand {
12315                request_id: "all-failed-schema".into(),
12316                statements: vec![SqlStatement {
12317                    sql: "CREATE TABLE all_failed(value TEXT UNIQUE)".into(),
12318                    parameters: vec![],
12319                }],
12320            })
12321            .unwrap();
12322        runtime
12323            .execute_sql(SqlCommand {
12324                request_id: "all-failed-existing".into(),
12325                statements: vec![SqlStatement {
12326                    sql: "INSERT INTO all_failed(value) VALUES ('existing')".into(),
12327                    parameters: vec![],
12328                }],
12329            })
12330            .unwrap();
12331
12332        let commit = runtime.lock_commit().unwrap();
12333        let mut workers = Vec::new();
12334        for id in 1..=2 {
12335            let worker_runtime = Arc::clone(&runtime);
12336            workers.push(std::thread::spawn(move || {
12337                worker_runtime
12338                    .execute_sql_batch(vec![SqlCommand {
12339                        request_id: format!("all-failed-{id}"),
12340                        statements: vec![SqlStatement {
12341                            sql: "INSERT INTO all_failed(value) VALUES ('existing')".into(),
12342                            parameters: vec![],
12343                        }],
12344                    }])
12345                    .unwrap()
12346            }));
12347            runtime
12348                .sql_group_commit
12349                .wait_for_pending_calls(id as usize, Duration::from_secs(5));
12350        }
12351        drop(commit);
12352
12353        for worker in workers {
12354            let results = worker.join().unwrap();
12355            assert_eq!(results.len(), 1);
12356            assert!(matches!(
12357                results[0],
12358                Err(NodeError::InvalidSqlStatement { .. })
12359            ));
12360        }
12361        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
12362    }
12363
12364    #[test]
12365    fn sql_group_commit_lone_call_completes_after_the_bounded_collection_round() {
12366        let (_dir, runtime) = sql_test_runtime();
12367
12368        let result = runtime
12369            .execute_sql_batch(vec![SqlCommand {
12370                request_id: "lone-call".into(),
12371                statements: vec![SqlStatement {
12372                    sql: "CREATE TABLE lone_call(id INTEGER PRIMARY KEY)".into(),
12373                    parameters: vec![],
12374                }],
12375            }])
12376            .unwrap();
12377
12378        assert!(result[0].is_ok());
12379        let queue = runtime
12380            .sql_group_commit
12381            .state
12382            .lock()
12383            .unwrap_or_else(std::sync::PoisonError::into_inner);
12384        assert!(queue.pending.is_empty());
12385        assert!(!queue.leader_active);
12386    }
12387
12388    #[test]
12389    fn key_value_write_commits_qwal_instead_of_raw_put_payload() {
12390        let (_dir, runtime) = sql_test_runtime();
12391
12392        let response = runtime.write("key-value-put", "key", "value").unwrap();
12393
12394        let entry = runtime
12395            .log_store()
12396            .read(response.applied_index)
12397            .unwrap()
12398            .unwrap();
12399        assert!(entry.payload.starts_with(QWAL_V3_MAGIC));
12400        assert!(!entry.payload.starts_with(b"put\t"));
12401        assert_eq!(
12402            runtime.read("key", ReadConsistency::Local).unwrap().value,
12403            Some("value".into())
12404        );
12405    }
12406
12407    #[test]
12408    fn sql_batch_preserves_order_commits_one_qwal_effect_and_retries_exactly() {
12409        let (_dir, runtime) = sql_test_runtime();
12410        runtime
12411            .execute_sql(SqlCommand {
12412                request_id: "schema".into(),
12413                statements: vec![SqlStatement {
12414                    sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
12415                        .into(),
12416                    parameters: vec![],
12417                }],
12418            })
12419            .unwrap();
12420        let commands = (1..=3)
12421            .map(|id| SqlCommand {
12422                request_id: format!("insert-{id}"),
12423                statements: vec![SqlStatement {
12424                    sql: "INSERT INTO batch_items(id, value) VALUES (?1, ?2)".into(),
12425                    parameters: vec![SqlValue::Integer(id), SqlValue::Text(format!("value-{id}"))],
12426                }],
12427            })
12428            .collect::<Vec<_>>();
12429
12430        let first = runtime.execute_sql_batch(commands.clone()).unwrap();
12431        let first_indices = first
12432            .iter()
12433            .map(|result| result.as_ref().unwrap().applied_index)
12434            .collect::<Vec<_>>();
12435        let log_index = runtime.log_store().last_index().unwrap();
12436        let replay = runtime.execute_sql_batch(commands).unwrap();
12437
12438        assert_eq!(first_indices, vec![2, 2, 2]);
12439        assert_eq!(
12440            first
12441                .iter()
12442                .map(|result| result.as_ref().unwrap().hash)
12443                .collect::<std::collections::HashSet<_>>()
12444                .len(),
12445            1
12446        );
12447        for index in 1..=2 {
12448            let entry = runtime.log_store().read(index).unwrap().unwrap();
12449            assert!(entry.payload.starts_with(QWAL_V3_MAGIC));
12450        }
12451        assert_eq!(
12452            replay
12453                .iter()
12454                .map(|result| result.as_ref().unwrap().applied_index)
12455                .collect::<Vec<_>>(),
12456            first_indices
12457        );
12458        assert_eq!(runtime.log_store().last_index().unwrap(), log_index);
12459    }
12460
12461    #[test]
12462    fn sql_effect_over_qlog_limit_is_resource_exhausted() {
12463        let (_dir, runtime) = sql_test_runtime();
12464        runtime
12465            .execute_sql(SqlCommand {
12466                request_id: "schema".into(),
12467                statements: vec![SqlStatement {
12468                    sql: "CREATE TABLE large_effect(value BLOB NOT NULL)".into(),
12469                    parameters: vec![],
12470                }],
12471            })
12472            .unwrap();
12473
12474        let error = runtime
12475            .execute_sql(SqlCommand {
12476                request_id: "large-effect".into(),
12477                statements: vec![SqlStatement {
12478                    sql: "INSERT INTO large_effect(value) VALUES (randomblob(700000))".into(),
12479                    parameters: vec![],
12480                }],
12481            })
12482            .unwrap_err();
12483
12484        assert!(matches!(error, NodeError::ResourceExhausted(_)));
12485        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
12486    }
12487
12488    #[test]
12489    fn sql_batch_isolates_request_conflict_from_unrelated_member() {
12490        let (_dir, runtime) = sql_test_runtime();
12491        runtime
12492            .execute_sql(SqlCommand {
12493                request_id: "schema".into(),
12494                statements: vec![SqlStatement {
12495                    sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
12496                        .into(),
12497                    parameters: vec![],
12498                }],
12499            })
12500            .unwrap();
12501        let insert = |request_id: &str, id: i64| SqlCommand {
12502            request_id: request_id.into(),
12503            statements: vec![SqlStatement {
12504                sql: "INSERT INTO batch_items(id, value) VALUES (?1, ?2)".into(),
12505                parameters: vec![SqlValue::Integer(id), SqlValue::Text(format!("value-{id}"))],
12506            }],
12507        };
12508
12509        let results = runtime
12510            .execute_sql_batch(vec![
12511                insert("same", 1),
12512                insert("same", 2),
12513                insert("other", 3),
12514            ])
12515            .unwrap();
12516
12517        assert!(results[0].is_ok());
12518        assert!(matches!(results[1], Err(NodeError::RequestConflict(_))));
12519        let conflict = results[1].as_ref().unwrap_err().classification();
12520        assert_eq!(conflict.code(), "request_conflict");
12521        assert_eq!(conflict.category(), ErrorCategory::Conflict);
12522        assert!(!conflict.retryable());
12523        assert!(results[2].is_ok());
12524        assert_eq!(
12525            results[0].as_ref().unwrap().applied_index,
12526            results[2].as_ref().unwrap().applied_index
12527        );
12528        assert!(runtime.is_ready());
12529    }
12530
12531    #[cfg(feature = "graph")]
12532    #[test]
12533    fn typed_batch_wrong_profile_is_rejected_before_log_attempt() {
12534        let (_dir, runtime) = graph_test_runtime();
12535        let command = SqlCommand {
12536            request_id: "wrong-profile".into(),
12537            statements: vec![SqlStatement {
12538                sql: "CREATE TABLE should_not_exist(id INTEGER PRIMARY KEY)".into(),
12539                parameters: vec![],
12540            }],
12541        };
12542
12543        let error = runtime.execute_sql_batch(vec![command]).unwrap_err();
12544
12545        assert!(matches!(
12546            error,
12547            NodeError::ExecutionProfileMismatch {
12548                expected: ExecutionProfile::Sqlite,
12549                actual: ExecutionProfile::Graph
12550            }
12551        ));
12552        assert_eq!(runtime.log_store().last_index().unwrap(), None);
12553    }
12554
12555    #[cfg(feature = "graph")]
12556    #[test]
12557    fn graph_query_timeout_returns_503_without_latching_readiness() {
12558        let (_dir, runtime) = graph_test_runtime();
12559        let graph = runtime.graph_materializer().unwrap();
12560        let graph_error = graph
12561            .query_read_only(
12562                "UNWIND range(1, 10000) AS x UNWIND range(1, 10000) AS y RETURN sum(x * y) AS total LIMIT 1",
12563                &std::collections::BTreeMap::new(),
12564                1,
12565                1024 * 1024,
12566                1,
12567            )
12568            .unwrap_err();
12569
12570        let response = node_error_response(runtime.map_graph_read_error(graph_error));
12571
12572        assert_eq!(
12573            response.status(),
12574            axum::http::StatusCode::SERVICE_UNAVAILABLE
12575        );
12576        assert!(runtime.is_ready());
12577        assert!(!runtime.is_fatal());
12578    }
12579
12580    #[cfg(feature = "graph")]
12581    #[test]
12582    fn graph_internal_error_returns_500_and_latches_readiness() {
12583        let (_dir, runtime) = graph_test_runtime();
12584
12585        let error =
12586            runtime.map_graph_read_error(rhiza_graph::Error::Ladybug("connection failed".into()));
12587        let response = node_error_response(error);
12588
12589        assert_eq!(
12590            response.status(),
12591            axum::http::StatusCode::INTERNAL_SERVER_ERROR
12592        );
12593        assert!(!runtime.is_ready());
12594        assert!(runtime.is_fatal());
12595    }
12596
12597    fn sql_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12598        sql_test_runtime_configured(None, None)
12599    }
12600
12601    fn sql_test_runtime_with_profiler(
12602        profiler: Option<SqlWriteProfiler>,
12603    ) -> (tempfile::TempDir, NodeRuntime) {
12604        sql_test_runtime_configured(profiler, None)
12605    }
12606
12607    fn sql_test_runtime_configured(
12608        profiler: Option<SqlWriteProfiler>,
12609        queue_capacity: Option<usize>,
12610    ) -> (tempfile::TempDir, NodeRuntime) {
12611        let dir = tempfile::tempdir().unwrap();
12612        let cluster_id = "node-unit-test";
12613        let mut config = NodeConfig::new_embedded(
12614            cluster_id,
12615            "n1",
12616            dir.path().join("node"),
12617            1,
12618            1,
12619            ["n1", "n2", "n3"],
12620        )
12621        .unwrap()
12622        .with_execution_profile(ExecutionProfile::Sqlite)
12623        .unwrap();
12624        if let Some(profiler) = profiler {
12625            config = config.with_sql_write_profiler(profiler);
12626        }
12627        if let Some(queue_capacity) = queue_capacity {
12628            config = config
12629                .with_sql_group_commit_queue_capacity(queue_capacity)
12630                .unwrap();
12631        }
12632        let consensus = Arc::new(
12633            ThreeNodeConsensus::from_recovered_tip(
12634                "rhiza:sql:node-unit-test",
12635                "n1",
12636                1,
12637                1,
12638                [
12639                    dir.path().join("recorders/n1"),
12640                    dir.path().join("recorders/n2"),
12641                    dir.path().join("recorders/n3"),
12642                ],
12643                1,
12644                LogHash::ZERO,
12645            )
12646            .unwrap(),
12647        );
12648        let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12649        (dir, runtime)
12650    }
12651
12652    #[cfg(feature = "graph")]
12653    fn graph_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12654        let dir = tempfile::tempdir().unwrap();
12655        let cluster_id = "node-unit-test";
12656        let config = NodeConfig::new_embedded(
12657            cluster_id,
12658            "n1",
12659            dir.path().join("node"),
12660            1,
12661            1,
12662            ["n1", "n2", "n3"],
12663        )
12664        .unwrap()
12665        .with_execution_profile(ExecutionProfile::Graph)
12666        .unwrap();
12667        let consensus = Arc::new(
12668            ThreeNodeConsensus::from_recovered_tip(
12669                "rhiza:graph:node-unit-test",
12670                "n1",
12671                1,
12672                1,
12673                [
12674                    dir.path().join("recorders/n1"),
12675                    dir.path().join("recorders/n2"),
12676                    dir.path().join("recorders/n3"),
12677                ],
12678                1,
12679                LogHash::ZERO,
12680            )
12681            .unwrap(),
12682        );
12683        let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12684        (dir, runtime)
12685    }
12686
12687    #[cfg(feature = "kv")]
12688    fn kv_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12689        let dir = tempfile::tempdir().unwrap();
12690        let cluster_id = "node-unit-test";
12691        let config = NodeConfig::new_embedded(
12692            cluster_id,
12693            "n1",
12694            dir.path().join("node"),
12695            1,
12696            1,
12697            ["n1", "n2", "n3"],
12698        )
12699        .unwrap()
12700        .with_execution_profile(ExecutionProfile::Kv)
12701        .unwrap();
12702        let consensus = Arc::new(
12703            ThreeNodeConsensus::from_recovered_tip(
12704                "rhiza:kv:node-unit-test",
12705                "n1",
12706                1,
12707                1,
12708                [
12709                    dir.path().join("recorders/n1"),
12710                    dir.path().join("recorders/n2"),
12711                    dir.path().join("recorders/n3"),
12712                ],
12713                1,
12714                LogHash::ZERO,
12715            )
12716            .unwrap(),
12717        );
12718        let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12719        (dir, runtime)
12720    }
12721}
12722
12723#[cfg(feature = "kv")]
12724fn largest_fitting_kv_batch_prefix(commands: &[KvCommandV1]) -> Option<(usize, Vec<u8>)> {
12725    if commands.len() < 3 {
12726        return None;
12727    }
12728    let mut lower = 2_usize;
12729    let mut upper = commands.len() - 1;
12730    let mut largest = None;
12731    while lower <= upper {
12732        let count = lower + (upper - lower) / 2;
12733        let payload = encode_replicated_kv_batch(&commands[..count])
12734            .expect("the validated KV batch prefix remains valid");
12735        if payload.len() <= MAX_COMMAND_BYTES {
12736            largest = Some((count, payload));
12737            lower = count + 1;
12738        } else {
12739            upper = count - 1;
12740        }
12741    }
12742    largest
12743}
12744
12745#[cfg(feature = "graph")]
12746fn validate_typed_batch_len(len: usize) -> Result<(), NodeError> {
12747    if (1..=MAX_WRITE_BATCH_MEMBERS).contains(&len) {
12748        Ok(())
12749    } else {
12750        Err(NodeError::InvalidRequest(format!(
12751            "write batch must contain 1..={MAX_WRITE_BATCH_MEMBERS} commands"
12752        )))
12753    }
12754}
12755
12756#[cfg(feature = "kv")]
12757fn validate_kv_batch_len(len: usize) -> Result<(), NodeError> {
12758    if (1..=MAX_KV_BATCH_MEMBERS).contains(&len) {
12759        Ok(())
12760    } else {
12761        Err(NodeError::InvalidRequest(format!(
12762            "KV write batch must contain 1..={MAX_KV_BATCH_MEMBERS} commands"
12763        )))
12764    }
12765}
12766
12767#[cfg(feature = "sql")]
12768fn validate_sql_batch_len(len: usize) -> Result<(), NodeError> {
12769    if (1..=MAX_TYPED_SQL_WRITE_BATCH_MEMBERS).contains(&len) {
12770        Ok(())
12771    } else {
12772        Err(NodeError::InvalidRequest(format!(
12773            "SQL write batch must contain 1..={MAX_TYPED_SQL_WRITE_BATCH_MEMBERS} commands"
12774        )))
12775    }
12776}
12777
12778fn validate_command_size(payload: &[u8]) -> Result<(), NodeError> {
12779    if payload.len() <= MAX_COMMAND_BYTES {
12780        Ok(())
12781    } else {
12782        Err(NodeError::InvalidRequest(format!(
12783            "command exceeds {MAX_COMMAND_BYTES} bytes"
12784        )))
12785    }
12786}
12787
12788#[cfg(feature = "sql")]
12789fn canonical_put(request_id: &str, key: &str, value: &str) -> Result<Vec<u8>, NodeError> {
12790    validate_field("request_id", request_id, MAX_REQUEST_ID_BYTES, false)?;
12791    validate_key(key)?;
12792    validate_field("value", value, MAX_VALUE_BYTES, true)?;
12793    let payload = encode_put_request(request_id, key, value)
12794        .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
12795    validate_command_size(&payload)?;
12796    Ok(payload)
12797}
12798
12799#[cfg(feature = "sql")]
12800fn encode_sql_command_with_index(command: &SqlCommand) -> Result<Vec<u8>, NodeError> {
12801    encode_sql_command(command).map_err(|error| {
12802        let message = error.to_string();
12803        match first_invalid_sql_statement(command, |prefix| encode_sql_command(prefix).is_err()) {
12804            Some(statement_index) => NodeError::InvalidSqlStatement {
12805                statement_index,
12806                message,
12807            },
12808            None => NodeError::InvalidRequest(message),
12809        }
12810    })
12811}
12812
12813#[cfg(feature = "sql")]
12814fn first_invalid_sql_statement(
12815    command: &SqlCommand,
12816    mut invalid: impl FnMut(&SqlCommand) -> bool,
12817) -> Option<usize> {
12818    if command.statements.is_empty() || command.statements.len() > MAX_SQL_STATEMENTS {
12819        return None;
12820    }
12821    (0..command.statements.len()).find(|statement_index| {
12822        let prefix = SqlCommand {
12823            request_id: command.request_id.clone(),
12824            statements: command.statements[..=*statement_index].to_vec(),
12825        };
12826        invalid(&prefix)
12827    })
12828}
12829
12830#[cfg(feature = "sql")]
12831fn validate_key(key: &str) -> Result<(), NodeError> {
12832    validate_field("key", key, MAX_KEY_BYTES, false)
12833}
12834
12835#[cfg(feature = "sql")]
12836fn validate_field(
12837    name: &str,
12838    value: &str,
12839    max_bytes: usize,
12840    allow_empty: bool,
12841) -> Result<(), NodeError> {
12842    if !allow_empty && value.is_empty() {
12843        return Err(NodeError::InvalidRequest(format!(
12844            "{name} must not be empty"
12845        )));
12846    }
12847    if value.len() > max_bytes {
12848        return Err(NodeError::InvalidRequest(format!(
12849            "{name} exceeds {max_bytes} bytes"
12850        )));
12851    }
12852    if value.contains('\t') {
12853        return Err(NodeError::InvalidRequest(format!(
12854            "{name} must not contain a tab"
12855        )));
12856    }
12857    Ok(())
12858}
12859
12860#[cfg(feature = "sql")]
12861fn write_response(outcome: RequestOutcome) -> WriteResponse {
12862    WriteResponse {
12863        applied_index: outcome.original_log_index(),
12864        hash: outcome.original_log_hash(),
12865    }
12866}
12867
12868fn reconcile_local_storage(
12869    config: &NodeConfig,
12870    log_store: &FileLogStore,
12871    materializer: &Materializer,
12872) -> Result<(), NodeError> {
12873    let mut log_state = log_store
12874        .logical_state()
12875        .map_err(|error| NodeError::Storage(error.to_string()))?;
12876    let mut log_last_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
12877    let applied_index = materializer
12878        .applied_index()
12879        .map_err(|error| NodeError::Storage(error.to_string()))?;
12880    let applied_hash = materializer
12881        .applied_hash()
12882        .map_err(|error| NodeError::Storage(error.to_string()))?;
12883    let mut materializer_configuration = materializer
12884        .configuration_state()
12885        .map_err(|error| NodeError::Storage(error.to_string()))?;
12886
12887    if let Some(anchor) = &log_state.anchor {
12888        if anchor.recovery_generation() != config.recovery_generation {
12889            return Err(NodeError::Reconciliation(format!(
12890                "qlog anchor recovery generation {} differs from runtime generation {}",
12891                anchor.recovery_generation(),
12892                config.recovery_generation
12893            )));
12894        }
12895        if applied_index < anchor.compacted().index() {
12896            return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())));
12897        }
12898    }
12899    if applied_index > log_last_index {
12900        let entries: Option<Vec<LogEntry>> = match materializer {
12901            #[cfg(feature = "sql")]
12902            Materializer::Sql(sql) => Some(
12903                sql.embedded_log_entries(log_last_index.saturating_add(1), applied_index)
12904                    .map_err(|error| NodeError::Reconciliation(error.to_string()))?,
12905            ),
12906            #[cfg(feature = "kv")]
12907            Materializer::Kv(kv) => Some(
12908                kv.embedded_log_entries(log_last_index.saturating_add(1), applied_index)
12909                    .map_err(|error| NodeError::Reconciliation(error.to_string()))?,
12910            ),
12911            #[cfg(feature = "graph")]
12912            Materializer::Graph(_) => None,
12913            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
12914            Materializer::Unavailable => None,
12915        };
12916        if let Some(entries) = entries {
12917            log_store
12918                .append_batch(&entries)
12919                .map_err(|error| NodeError::Storage(error.to_string()))?;
12920            log_state = log_store
12921                .logical_state()
12922                .map_err(|error| NodeError::Storage(error.to_string()))?;
12923            log_last_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
12924        }
12925    }
12926    if applied_index > log_last_index {
12927        return Err(NodeError::Reconciliation(format!(
12928            "{} materializer is ahead at {applied_index}, qlog ends at {log_last_index}",
12929            materializer.profile()
12930        )));
12931    }
12932    if applied_index == 0 {
12933        if applied_hash != LogHash::ZERO {
12934            return Err(NodeError::Reconciliation(format!(
12935                "{} materializer genesis hash is not zero",
12936                materializer.profile()
12937            )));
12938        }
12939    } else if !log_state.anchor.as_ref().is_some_and(|anchor| {
12940        applied_index == anchor.compacted().index() && applied_hash == anchor.compacted().hash()
12941    }) {
12942        let entry = log_store
12943            .read(applied_index)
12944            .map_err(|error| NodeError::Storage(error.to_string()))?
12945            .ok_or_else(|| {
12946                NodeError::Reconciliation(format!(
12947                    "qlog prefix is missing {} materializer index {applied_index}",
12948                    materializer.profile()
12949                ))
12950            })?;
12951        validate_entry_envelope(config, &entry, applied_index, entry.prev_hash)?;
12952        if entry.hash != applied_hash {
12953            return Err(NodeError::Reconciliation(format!(
12954                "{} materializer hash diverges from qlog at index {applied_index}",
12955                materializer.profile()
12956            )));
12957        }
12958    }
12959
12960    let mut expected_prev_hash = applied_hash;
12961    for index in (applied_index + 1)..=log_last_index {
12962        let entry = log_store
12963            .read(index)
12964            .map_err(|error| NodeError::Storage(error.to_string()))?
12965            .ok_or_else(|| {
12966                NodeError::Reconciliation(format!("qlog prefix is missing index {index}"))
12967            })?;
12968        match &materializer_configuration {
12969            Some(configuration) => {
12970                validate_runtime_entry(config, configuration, &entry, index, expected_prev_hash)?
12971            }
12972            None => validate_entry_envelope(config, &entry, index, expected_prev_hash)?,
12973        };
12974        materializer
12975            .apply_entry(&entry)
12976            .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12977        materializer_configuration = materializer
12978            .configuration_state()
12979            .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12980        expected_prev_hash = entry.hash;
12981    }
12982    let log_configuration = log_store
12983        .configuration_state()
12984        .map_err(|error| NodeError::Storage(error.to_string()))?;
12985    if materializer_configuration
12986        .as_ref()
12987        .is_some_and(|configuration| configuration != &log_configuration)
12988    {
12989        return Err(NodeError::Reconciliation(format!(
12990            "qlog and {} materializer configuration states disagree",
12991            materializer.profile()
12992        )));
12993    }
12994    Ok(())
12995}
12996
12997fn recover_peer_candidates(
12998    config: &NodeConfig,
12999    consensus: &ThreeNodeConsensus,
13000    log_store: &FileLogStore,
13001    materializer: &Materializer,
13002    peer_candidates: &[&dyn LogPeer],
13003    startup: &StartupIoContext,
13004) -> Result<(), NodeError> {
13005    for peer in peer_candidates {
13006        let (last_index, last_hash) = static_log_tip(log_store)?;
13007        startup.check("peer log fetch")?;
13008        let candidates = match peer.fetch_log(FetchLogRequest {
13009            from_index: last_index.saturating_add(1),
13010            max_entries: MAX_FETCH_ENTRIES,
13011        }) {
13012            Ok(response) => validate_fetched_entries_with_configuration(
13013                last_index.saturating_add(1),
13014                last_hash,
13015                &config.cluster_id,
13016                config.epoch,
13017                log_store
13018                    .configuration_state()
13019                    .map_err(|error| NodeError::Storage(error.to_string()))?,
13020                response.entries,
13021            )
13022            .map_err(|error| {
13023                NodeError::Reconciliation(format!("peer candidate validation failed: {error}"))
13024            })?,
13025            Err(FetchLogError::Transport { .. }) => continue,
13026            Err(FetchLogError::SnapshotRequired { anchor }) => {
13027                return Err(NodeError::SnapshotRequired(anchor));
13028            }
13029            Err(error) => {
13030                return Err(NodeError::Reconciliation(format!(
13031                    "peer candidate validation failed: {error}"
13032                )));
13033            }
13034        };
13035        startup.check("peer log fetch")?;
13036
13037        let mut expected_index = last_index.checked_add(1).ok_or_else(|| {
13038            NodeError::Reconciliation("qlog index is exhausted during peer catch-up".into())
13039        })?;
13040        let mut expected_prev_hash = last_hash;
13041        for candidate in candidates {
13042            startup.check("peer decision inspection")?;
13043            match consensus
13044                .inspect_decision_at(expected_index, expected_prev_hash)
13045                .map_err(startup_consensus_error)?
13046            {
13047                DecisionInspection::Committed(committed) if committed == candidate => {
13048                    startup.check("peer decision inspection")?;
13049                    persist_startup_entry(
13050                        config,
13051                        log_store,
13052                        materializer,
13053                        &candidate,
13054                        expected_index,
13055                        expected_prev_hash,
13056                        startup,
13057                    )?;
13058                    expected_prev_hash = candidate.hash;
13059                    expected_index = expected_index.checked_add(1).ok_or_else(|| {
13060                        NodeError::Reconciliation(
13061                            "qlog index is exhausted during peer catch-up".into(),
13062                        )
13063                    })?;
13064                }
13065                DecisionInspection::Committed(_) => {
13066                    return Err(NodeError::Reconciliation(format!(
13067                        "peer candidate at index {expected_index} differs from committed decision"
13068                    )));
13069                }
13070                DecisionInspection::Unavailable => {
13071                    return Err(NodeError::Unavailable(format!(
13072                        "decision inspection unavailable for peer candidate at index {expected_index}"
13073                    )));
13074                }
13075                DecisionInspection::Empty | DecisionInspection::Pending => {
13076                    return Err(NodeError::Reconciliation(format!(
13077                        "peer candidate at index {expected_index} is not committed"
13078                    )));
13079                }
13080            }
13081        }
13082    }
13083    Ok(())
13084}
13085
13086fn recover_startup_decisions(
13087    config: &NodeConfig,
13088    consensus: &ThreeNodeConsensus,
13089    log_store: &FileLogStore,
13090    materializer: &Materializer,
13091    startup: &StartupIoContext,
13092) -> Result<(), NodeError> {
13093    for _ in 0..MAX_STARTUP_RECOVERY_ENTRIES {
13094        let (last_index, last_hash) = static_log_tip(log_store)?;
13095        let slot = last_index.checked_add(1).ok_or_else(|| {
13096            NodeError::Reconciliation("qlog index is exhausted during startup".into())
13097        })?;
13098        startup.check("recorder decision inspection")?;
13099        match consensus
13100            .inspect_decision_at(slot, last_hash)
13101            .map_err(startup_consensus_error)?
13102        {
13103            DecisionInspection::Committed(entry) => {
13104                startup.check("recorder decision inspection")?;
13105                persist_startup_entry(
13106                    config,
13107                    log_store,
13108                    materializer,
13109                    &entry,
13110                    slot,
13111                    last_hash,
13112                    startup,
13113                )?;
13114            }
13115            DecisionInspection::Pending => {
13116                let entry = consensus
13117                    .propose_at_cancellable(
13118                        slot,
13119                        last_hash,
13120                        Command::new(CommandKind::ReadBarrier, Vec::new()),
13121                        startup.cancellation_flag(),
13122                    )
13123                    .map_err(startup_consensus_error)?;
13124                startup.check("recorder pending decision recovery")?;
13125                persist_startup_entry(
13126                    config,
13127                    log_store,
13128                    materializer,
13129                    &entry,
13130                    slot,
13131                    last_hash,
13132                    startup,
13133                )?;
13134            }
13135            DecisionInspection::Empty => return Ok(()),
13136            DecisionInspection::Unavailable => {
13137                return Err(NodeError::Unavailable(
13138                    "decision inspection unavailable during startup".into(),
13139                ));
13140            }
13141        }
13142    }
13143    Err(NodeError::Reconciliation(format!(
13144        "startup recovery exceeded {MAX_STARTUP_RECOVERY_ENTRIES} entries"
13145    )))
13146}
13147
13148fn persist_startup_entry(
13149    config: &NodeConfig,
13150    log_store: &FileLogStore,
13151    materializer: &Materializer,
13152    entry: &LogEntry,
13153    expected_index: LogIndex,
13154    expected_prev_hash: LogHash,
13155    startup: &StartupIoContext,
13156) -> Result<(), NodeError> {
13157    startup.persist("startup qlog and materializer persistence", || {
13158        let configuration_state = log_store
13159            .configuration_state()
13160            .map_err(|error| NodeError::Storage(error.to_string()))?;
13161        validate_runtime_entry(
13162            config,
13163            &configuration_state,
13164            entry,
13165            expected_index,
13166            expected_prev_hash,
13167        )?;
13168        log_store
13169            .append(entry)
13170            .map_err(|error| NodeError::Storage(error.to_string()))?;
13171        materializer
13172            .apply_entry(entry)
13173            .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
13174        Ok(())
13175    })
13176}
13177
13178fn static_log_tip(log_store: &FileLogStore) -> Result<(LogIndex, LogHash), NodeError> {
13179    Ok(log_store
13180        .logical_state()
13181        .map_err(|error| NodeError::Storage(error.to_string()))?
13182        .tip
13183        .map_or((0, LogHash::ZERO), |tip| (tip.index(), tip.hash())))
13184}
13185
13186fn validate_runtime_entry(
13187    config: &NodeConfig,
13188    configuration_state: &ConfigurationState,
13189    entry: &LogEntry,
13190    expected_index: LogIndex,
13191    expected_prev_hash: LogHash,
13192) -> Result<(), NodeError> {
13193    validate_entry_envelope(config, entry, expected_index, expected_prev_hash)?;
13194    validate_profile_entry_shape(config.execution_profile(), entry)
13195        .map_err(NodeError::Invariant)?;
13196    configuration_state
13197        .validate_entry(entry)
13198        .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
13199    Ok(())
13200}
13201
13202fn validate_entry_envelope(
13203    config: &NodeConfig,
13204    entry: &LogEntry,
13205    expected_index: LogIndex,
13206    expected_prev_hash: LogHash,
13207) -> Result<(), NodeError> {
13208    if entry.index != expected_index {
13209        return Err(NodeError::Reconciliation(format!(
13210            "expected decision index {expected_index}, got {}",
13211            entry.index
13212        )));
13213    }
13214    if entry.cluster_id != config.cluster_id || entry.epoch != config.epoch {
13215        return Err(NodeError::Reconciliation(format!(
13216            "decision {} has a foreign identity",
13217            entry.index
13218        )));
13219    }
13220    if entry.prev_hash != expected_prev_hash {
13221        return Err(NodeError::Reconciliation(format!(
13222            "decision {} has a conflicting predecessor",
13223            entry.index
13224        )));
13225    }
13226    if entry.recompute_hash() != entry.hash {
13227        return Err(NodeError::Reconciliation(format!(
13228            "decision {} has an invalid hash",
13229            entry.index
13230        )));
13231    }
13232    validate_entry_shape(entry).map_err(NodeError::Invariant)
13233}
13234
13235fn validate_entry_shape(entry: &LogEntry) -> Result<(), String> {
13236    match entry.entry_type {
13237        EntryType::Command if entry.payload.len() <= MAX_COMMAND_BYTES => Ok(()),
13238        EntryType::Command => Err(format!("command exceeds {MAX_COMMAND_BYTES} bytes")),
13239        EntryType::Noop if entry.payload.is_empty() => Ok(()),
13240        EntryType::Noop => Err("Noop payload must be empty".into()),
13241        EntryType::ConfigChange => ConfigChange::recognize(&StoredCommand::new(
13242            EntryType::ConfigChange,
13243            entry.payload.clone(),
13244        ))
13245        .map(|_| ())
13246        .map_err(|error| error.to_string()),
13247        other => Err(format!("unsupported runtime entry type {other:?}")),
13248    }
13249}
13250
13251pub(crate) fn validate_profile_entry_shape(
13252    _profile: ExecutionProfile,
13253    entry: &LogEntry,
13254) -> Result<(), String> {
13255    validate_entry_shape(entry)?;
13256    #[cfg(feature = "sql")]
13257    if _profile == ExecutionProfile::Sqlite && entry.entry_type == EntryType::Command {
13258        decode_qwal_v3(&entry.payload)
13259            .map_err(|error| format!("SQLite command is not canonical QWAL v3: {error}"))?;
13260    }
13261    Ok(())
13262}
13263
13264fn startup_consensus_error(error: rhiza_quepaxa::Error) -> NodeError {
13265    match error {
13266        rhiza_quepaxa::Error::NoQuorum
13267        | rhiza_quepaxa::Error::CommandUnavailable
13268        | rhiza_quepaxa::Error::Cancelled
13269        | rhiza_quepaxa::Error::Io(_) => NodeError::Unavailable(error.to_string()),
13270        other => NodeError::Reconciliation(other.to_string()),
13271    }
13272}
13273
13274#[cfg(feature = "sql")]
13275#[derive(Clone, Debug, Eq, PartialEq)]
13276pub struct E2eConfig {
13277    pub data_dir: PathBuf,
13278    pub object_store: ObjStoreConfig,
13279    pub cluster_id: String,
13280    pub node_id: String,
13281}
13282
13283#[cfg(feature = "sql")]
13284#[derive(Clone, Debug, Eq, PartialEq)]
13285pub struct E2eReport {
13286    pub applied_index: LogIndex,
13287    pub restored_value: String,
13288    pub object_keys: Vec<String>,
13289}
13290
13291#[cfg(feature = "sql")]
13292pub async fn run_e2e(config: E2eConfig) -> Result<E2eReport, Box<dyn std::error::Error>> {
13293    let sqlite_dir = config.data_dir.join("sqlite");
13294    let log_dir = config.data_dir.join("consensus").join("log");
13295    ensure_fresh_e2e_data_dir(&config.data_dir, &sqlite_dir, &log_dir)?;
13296
13297    fs::create_dir_all(&config.data_dir)?;
13298    let db_path = sqlite_dir.join("db.sqlite");
13299    let restore_path = config.data_dir.join("restore").join("db.sqlite");
13300    let db = SqliteStateMachine::open(&db_path, &config.cluster_id, &config.node_id, 1, 1)?;
13301    let recorder_dir = config.data_dir.join("consensus").join("recorder");
13302    let consensus = ThreeNodeConsensus::new(
13303        &config.cluster_id,
13304        &config.node_id,
13305        1,
13306        1,
13307        [
13308            recorder_dir.join("node-1"),
13309            recorder_dir.join("node-2"),
13310            recorder_dir.join("node-3"),
13311        ],
13312    )?;
13313    let base_request = canonical_put("e2e-base", "alpha", "bravo")?;
13314    let base_effect = db.prepare_put_effect(
13315        "e2e-base",
13316        "alpha",
13317        "bravo",
13318        &base_request,
13319        0,
13320        LogHash::ZERO,
13321    )?;
13322    let base_entry = consensus.propose(Command::new(CommandKind::Deterministic, base_effect))?;
13323    db.apply_entry(&base_entry)?;
13324    let snapshot = db.create_snapshot(base_entry.index)?;
13325
13326    let tail_request = canonical_put("e2e-tail", "alpha", "charlie")?;
13327    let tail_effect = db.prepare_put_effect(
13328        "e2e-tail",
13329        "alpha",
13330        "charlie",
13331        &tail_request,
13332        base_entry.index,
13333        base_entry.hash,
13334    )?;
13335    let tail_entry = consensus.propose(Command::new(CommandKind::Deterministic, tail_effect))?;
13336    let segment_path = write_segment_file(&log_dir, std::slice::from_ref(&tail_entry))?;
13337    let segment = rhiza_log::SegmentFile::new(
13338        IndexRange::new(tail_entry.index, tail_entry.index)?,
13339        fs::read(&segment_path)?,
13340    );
13341    db.apply_entry(&tail_entry)?;
13342
13343    let local_archive = matches!(&config.object_store, ObjStoreConfig::Local { .. });
13344    let store = ObjStore::new(config.object_store)?;
13345    let archive = if local_archive {
13346        rhiza_archive::ObjectArchiveStore::new_for_single_process(
13347            store.clone(),
13348            config.cluster_id.clone(),
13349        )
13350    } else {
13351        rhiza_archive::ObjectArchiveStore::new(store.clone(), config.cluster_id.clone())?
13352    };
13353
13354    let segment_record = archive.publish_segment(tail_entry.epoch, &segment).await?;
13355    let snapshot_record = archive.publish_snapshot(&snapshot).await?;
13356    let (mut archive_manifest, expected_manifest_version) = match archive.load_manifest().await? {
13357        Some(loaded) => (loaded.manifest().clone(), Some(loaded.version().clone())),
13358        None => (
13359            rhiza_archive::ArchiveManifest::new(config.cluster_id.clone()),
13360            None,
13361        ),
13362    };
13363    archive_manifest.set_latest_snapshot(snapshot_record);
13364    archive_manifest.add_segment(segment_record);
13365    archive
13366        .publish_manifest(&archive_manifest, expected_manifest_version)
13367        .await?;
13368
13369    let loaded_manifest = archive
13370        .load_manifest()
13371        .await?
13372        .ok_or("published archive manifest is missing")?;
13373    if loaded_manifest.manifest() != &archive_manifest {
13374        return Err("reloaded archive manifest did not match the published manifest".into());
13375    }
13376    let archived_snapshot = loaded_manifest
13377        .manifest()
13378        .latest_snapshot()
13379        .ok_or("archive manifest is missing its snapshot")?;
13380    let archived_segment = loaded_manifest
13381        .manifest()
13382        .segments()
13383        .iter()
13384        .find(|record| {
13385            record.start_index() == tail_entry.index && record.end_index() == tail_entry.index
13386        })
13387        .ok_or("archive manifest is missing its post-snapshot segment")?;
13388
13389    let downloaded_segment = archive.download_segment(archived_segment).await?;
13390    let downloaded_entries = decode_segment_for_cluster(&downloaded_segment, &config.cluster_id)?;
13391    if downloaded_entries.as_slice() != std::slice::from_ref(&tail_entry) {
13392        return Err("downloaded qlog segment did not match written entry".into());
13393    }
13394    let downloaded_snapshot = rhiza_core::Snapshot::new(
13395        archived_snapshot.manifest().clone(),
13396        archive.download_snapshot(archived_snapshot).await?,
13397    );
13398    restore_snapshot_file(&restore_path, &downloaded_snapshot, &config.node_id)?;
13399    let restored_db = SqliteStateMachine::open_existing(&restore_path)?;
13400    if restored_db.get_value("alpha")?.as_deref() != Some("bravo") {
13401        return Err("restored base snapshot is missing alpha=bravo".into());
13402    }
13403    for entry in &downloaded_entries {
13404        restored_db.apply_entry(entry)?;
13405    }
13406    let restored_value = restored_db
13407        .get_value("alpha")?
13408        .ok_or("restored SQLite state is missing alpha")?;
13409    let applied_index = restored_db.applied_index_value()?;
13410    if applied_index != tail_entry.index || restored_value != "charlie" {
13411        return Err("restored SQLite state did not include the archived log tail".into());
13412    }
13413    let object_keys = store.list(&format!("rhiza/{}", config.cluster_id)).await?;
13414
13415    Ok(E2eReport {
13416        applied_index,
13417        restored_value,
13418        object_keys,
13419    })
13420}
13421
13422#[cfg(feature = "sql")]
13423fn ensure_fresh_e2e_data_dir(
13424    data_dir: &std::path::Path,
13425    sqlite_dir: &std::path::Path,
13426    log_dir: &std::path::Path,
13427) -> Result<(), Box<dyn std::error::Error>> {
13428    if directory_has_entries(sqlite_dir)? || directory_has_entries(log_dir)? {
13429        return Err(format!(
13430            "e2e data directory is not fresh: prior SQLite/qlog data exists in {}",
13431            data_dir.display()
13432        )
13433        .into());
13434    }
13435    Ok(())
13436}
13437
13438#[cfg(feature = "sql")]
13439fn directory_has_entries(path: &std::path::Path) -> Result<bool, std::io::Error> {
13440    match fs::read_dir(path) {
13441        Ok(mut entries) => entries.next().transpose().map(|entry| entry.is_some()),
13442        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
13443        Err(error) => Err(error),
13444    }
13445}