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,
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 recorder_tcp;
79pub use admin::*;
80pub use durability::{
81    restore_checkpoint_to_fresh_data_dir, restore_checkpoint_to_fresh_data_dir_for_node,
82    restore_successor_checkpoint_to_fresh_data_dir, CheckpointCoordinator, DurabilityError,
83    DurabilityHealth, DurabilityMode, SuccessorRestorePreparation,
84};
85#[cfg(feature = "graph")]
86pub use graph::*;
87#[cfg(feature = "kv")]
88pub use kv::*;
89#[cfg(feature = "recorder-postcard-rpc")]
90pub use recorder_tcp::{
91    serve_recorder_postcard_rpc, serve_recorder_postcard_rpc_tls,
92    RecorderPostcardRpcTlsClientConfig, RecorderPostcardRpcTlsServerConfig,
93    TcpPostcardRpcRecorderClient,
94};
95pub use recorder_tcp::{
96    serve_recorder_tcp, serve_recorder_tcp_tls, validate_recorder_tcp_endpoint,
97    RecorderTlsClientConfig, RecorderTlsServerConfig, TcpPostcardRecorderClient,
98};
99
100pub const MAX_FETCH_ENTRIES: u32 = 1_024;
101pub const MAX_COMMAND_BYTES: usize = 512 * 1024;
102pub const MAX_REQUEST_ID_BYTES: usize = 256;
103pub const MAX_KEY_BYTES: usize = 4 * 1024;
104pub const MAX_VALUE_BYTES: usize = 240 * 1024;
105pub const MAX_HTTP_BODY_BYTES: usize = MAX_COMMAND_BYTES * 6 + 16 * 1024;
106pub const DEFAULT_CLIENT_CONCURRENCY: usize = 16;
107pub const DEFAULT_PEER_CONCURRENCY: usize = 32;
108pub const DEFAULT_WRITER_BATCH_MAX: usize = 8;
109const MAX_WRITE_BATCH_MEMBERS: usize = 64;
110#[cfg(feature = "sql")]
111const MAX_SQL_WRITE_BATCH_MEMBERS: usize = MAX_QWAL_V3_RECEIPTS;
112#[cfg(feature = "sql")]
113pub const MAX_TYPED_SQL_WRITE_BATCH_MEMBERS: usize = 256;
114#[cfg(feature = "sql")]
115pub const DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY: usize = 64;
116#[cfg(feature = "sql")]
117pub const MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY: usize = 4_096;
118// One full 1,024-receipt group is four aggregate-capped public calls.
119#[cfg(feature = "sql")]
120const MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES: usize = 4 * MAX_COMMAND_BYTES;
121// Keep the configured call limit for compatibility, but never retain more than 64 full calls.
122#[cfg(feature = "sql")]
123const MAX_SQL_GROUP_COMMIT_PENDING_BYTES: usize =
124    DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY * MAX_COMMAND_BYTES;
125#[cfg(feature = "kv")]
126const MAX_KV_GROUP_COMMIT_MEMBERS: usize = 1_024;
127#[cfg(feature = "kv")]
128const KV_GROUP_COMMIT_QUEUE_CAPACITY: usize = 64;
129#[cfg(feature = "kv")]
130const MAX_KV_GROUP_COMMIT_PENDING_BYTES: usize = KV_GROUP_COMMIT_QUEUE_CAPACITY * MAX_COMMAND_BYTES;
131pub const DEFAULT_WRITER_BATCH_WINDOW: Duration = Duration::from_micros(500);
132pub const PROTOCOL_VERSION: &str = "1";
133pub const RECORDER_PROTOCOL_VERSION: &str = "3";
134const RECORDER_WIRE_VERSION: u16 = 3;
135pub const VERSION_HEADER: &str = "x-rhiza-version";
136pub const NODE_ID_HEADER: &str = "x-rhiza-node-id";
137pub const RECOVERY_GENERATION_HEADER: &str = "x-rhiza-recovery-generation";
138pub const RECORDER_IDENTITY_PATH: &str = "/v2/quepaxa/recorder/identity";
139
140/// A bounded, clone-shared observer for successful physical SQL write batches.
141///
142/// Installing this observer enables write-phase timing. A runtime without an observer does not
143/// read the clock or synchronize on the profiling path.
144#[cfg(feature = "sql")]
145#[derive(Clone)]
146pub struct SqlWriteProfiler {
147    inner: Arc<SqlWriteProfilerInner>,
148}
149
150#[cfg(feature = "sql")]
151struct SqlWriteProfilerInner {
152    capacity: usize,
153    state: Mutex<SqlWriteProfilerState>,
154}
155
156#[cfg(feature = "sql")]
157#[derive(Default)]
158struct SqlWriteProfilerState {
159    samples: VecDeque<SqlWriteProfileSample>,
160    dropped_samples: u64,
161}
162
163/// Timing for one successfully committed physical SQL QWAL batch.
164#[cfg(feature = "sql")]
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct SqlWriteProfileSample {
167    pub batch_member_count: usize,
168    pub commit_lock_wait_us: u64,
169    pub precheck_classification_us: u64,
170    pub qwal_prepare_us: u64,
171    pub consensus_propose_us: u64,
172    pub local_qlog_mirror_append_us: u64,
173    pub sql_materializer_apply_us: u64,
174    pub response_other_total_us: u64,
175    pub total_service_us: u64,
176}
177
178/// A point-in-time copy of a SQL write profiler's bounded samples.
179#[cfg(feature = "sql")]
180#[derive(Clone, Debug, Default, Eq, PartialEq)]
181pub struct SqlWriteProfileSnapshot {
182    pub samples: Vec<SqlWriteProfileSample>,
183    pub dropped_samples: u64,
184}
185
186#[cfg(feature = "sql")]
187impl SqlWriteProfiler {
188    /// Creates an observer that retains at most `capacity` of the newest samples.
189    ///
190    /// # Panics
191    ///
192    /// Panics when `capacity` is zero.
193    pub fn new(capacity: usize) -> Self {
194        assert!(capacity > 0, "SQL write profiler capacity must be non-zero");
195        Self {
196            inner: Arc::new(SqlWriteProfilerInner {
197                capacity,
198                state: Mutex::new(SqlWriteProfilerState::default()),
199            }),
200        }
201    }
202
203    pub fn snapshot(&self) -> SqlWriteProfileSnapshot {
204        let state = self
205            .inner
206            .state
207            .lock()
208            .unwrap_or_else(std::sync::PoisonError::into_inner);
209        SqlWriteProfileSnapshot {
210            samples: state.samples.iter().cloned().collect(),
211            dropped_samples: state.dropped_samples,
212        }
213    }
214
215    /// Returns and clears retained samples while preserving the cumulative dropped count.
216    pub fn drain(&self) -> SqlWriteProfileSnapshot {
217        let mut state = self
218            .inner
219            .state
220            .lock()
221            .unwrap_or_else(std::sync::PoisonError::into_inner);
222        SqlWriteProfileSnapshot {
223            samples: state.samples.drain(..).collect(),
224            dropped_samples: state.dropped_samples,
225        }
226    }
227
228    fn record(&self, sample: SqlWriteProfileSample) {
229        let mut state = self
230            .inner
231            .state
232            .lock()
233            .unwrap_or_else(std::sync::PoisonError::into_inner);
234        if state.samples.len() == self.inner.capacity {
235            state.samples.pop_front();
236            state.dropped_samples = state.dropped_samples.saturating_add(1);
237        }
238        state.samples.push_back(sample);
239    }
240}
241
242#[cfg(feature = "sql")]
243impl fmt::Debug for SqlWriteProfiler {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        f.debug_struct("SqlWriteProfiler")
246            .field("capacity", &self.inner.capacity)
247            .finish_non_exhaustive()
248    }
249}
250
251#[cfg(feature = "sql")]
252impl PartialEq for SqlWriteProfiler {
253    fn eq(&self, other: &Self) -> bool {
254        Arc::ptr_eq(&self.inner, &other.inner)
255    }
256}
257
258#[cfg(feature = "sql")]
259impl Eq for SqlWriteProfiler {}
260pub const RECORDER_STORE_COMMAND_PATH: &str = "/v2/quepaxa/recorder/store-command";
261pub const RECORDER_FETCH_COMMAND_PATH: &str = "/v2/quepaxa/recorder/fetch-command";
262pub const RECORDER_INSPECT_PROOF_PATH: &str = "/v2/quepaxa/recorder/inspect-proof";
263pub const RECORDER_INSPECT_RECORD_PATH: &str = "/v2/quepaxa/recorder/inspect-record";
264pub const RECORDER_READ_FENCE_PATH: &str = "/v3/quepaxa/recorder/read-fence";
265pub const RECORDER_RECORD_PATH: &str = "/v2/quepaxa/recorder/record";
266pub const RECORDER_INSTALL_PROOF_PATH: &str = "/v2/quepaxa/recorder/install-decision-proof";
267pub const LOG_FETCH_PATH: &str = "/v1/log/fetch";
268#[cfg(feature = "sql")]
269pub const WRITE_PATH: &str = "/v1/write";
270#[cfg(feature = "sql")]
271pub const READ_PATH: &str = "/v1/read";
272#[cfg(feature = "sql")]
273pub const SQL_EXECUTE_PATH: &str = "/v1/sql/execute";
274#[cfg(feature = "sql")]
275pub const SQL_QUERY_PATH: &str = "/v1/sql/query";
276#[cfg(feature = "graph")]
277pub const GRAPH_PUT_DOCUMENT_PATH: &str = "/v1/graph/documents/put";
278#[cfg(feature = "graph")]
279pub const GRAPH_DELETE_DOCUMENT_PATH: &str = "/v1/graph/documents/delete";
280#[cfg(feature = "graph")]
281pub const GRAPH_GET_DOCUMENT_PATH: &str = "/v1/graph/documents/get";
282#[cfg(feature = "graph")]
283pub const GRAPH_QUERY_PATH: &str = "/v1/graph/query";
284#[cfg(feature = "kv")]
285pub const KV_PUT_PATH: &str = "/v1/kv/put";
286#[cfg(feature = "kv")]
287pub const KV_DELETE_PATH: &str = "/v1/kv/delete";
288#[cfg(feature = "kv")]
289pub const KV_GET_PATH: &str = "/v1/kv/get";
290#[cfg(feature = "kv")]
291pub const KV_SCAN_PATH: &str = "/v1/kv/scan";
292#[cfg(feature = "sql")]
293pub const SQL_EXECUTE_RESPONSE_VERSION: u16 = 1;
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    EmptyClientToken,
400    ClientTokenConflictsWithPeer,
401    EmptyAdminToken,
402    AdminTokenConflictsWithRuntime,
403    HttpClient(String),
404}
405
406impl fmt::Display for ConfigError {
407    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
408        match self {
409            Self::EmptyClusterId => write!(f, "cluster_id must not be empty"),
410            Self::ClusterIdProfileMismatch { expected, actual } => write!(
411                f,
412                "cluster_id is canonical for the {} profile, not {}",
413                actual.as_str(),
414                expected.as_str()
415            ),
416            Self::EmptyNodeId => write!(f, "node_id must not be empty"),
417            Self::EmptyDataDir => write!(f, "data_dir must not be empty"),
418            Self::InvalidEpoch => write!(f, "epoch must be positive"),
419            Self::InvalidConfigId => write!(f, "config_id must be positive"),
420            Self::InvalidRecoveryGeneration => {
421                write!(f, "recovery_generation must be positive")
422            }
423            Self::InvalidWriterBatchMax(max) => write!(
424                f,
425                "writer batch max must be within 1..={MAX_WRITE_BATCH_MEMBERS}, got {max}"
426            ),
427            Self::InvalidWriterBatchWindow => write!(
428                f,
429                "writer batch window must be positive and shorter than the client deadline"
430            ),
431            #[cfg(feature = "sql")]
432            Self::InvalidSqlGroupCommitQueueCapacity(capacity) => write!(
433                f,
434                "SQL group commit queue capacity must be within 1..={MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY}, got {capacity}"
435            ),
436            Self::EmptyPeerNodeId => write!(f, "peer node_id must not be empty"),
437            Self::EmptyPeerBaseUrl => write!(f, "peer base_url must not be empty"),
438            Self::InvalidPeerBaseUrl(url) => write!(f, "invalid peer base_url: {url}"),
439            Self::EmptyPeerToken => write!(f, "peer token must not be empty"),
440            Self::DuplicatePeerToken => write!(f, "peer tokens must be unique"),
441            Self::InvalidPeerCount(count) => {
442                write!(
443                    f,
444                    "peer membership requires between three and seven nodes, got {count}"
445                )
446            }
447            Self::DuplicatePeerNodeId(node_id) => {
448                write!(f, "peer node_id must be unique: {node_id}")
449            }
450            Self::LocalNodeMissing => write!(f, "peer set must include the local node_id"),
451            Self::PeerMembershipMismatch => {
452                write!(
453                    f,
454                    "peer identities must exactly match the canonical membership"
455                )
456            }
457            Self::EmptyClientToken => write!(f, "client token must not be empty"),
458            Self::ClientTokenConflictsWithPeer => {
459                write!(f, "client token must differ from every peer token")
460            }
461            Self::EmptyAdminToken => write!(f, "admin token must not be empty"),
462            Self::AdminTokenConflictsWithRuntime => {
463                write!(f, "admin token must differ from client and peer tokens")
464            }
465            Self::HttpClient(message) => write!(f, "HTTP client configuration failed: {message}"),
466        }
467    }
468}
469
470impl std::error::Error for ConfigError {}
471
472fn validate_recovery_generation(recovery_generation: u64) -> Result<(), ConfigError> {
473    if recovery_generation == 0 {
474        Err(ConfigError::InvalidRecoveryGeneration)
475    } else {
476        Ok(())
477    }
478}
479
480#[derive(Clone, Copy, Debug, Eq, PartialEq)]
481pub enum AckMode {
482    HaFirst,
483    DrStrong,
484}
485
486#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
487#[serde(rename_all = "snake_case")]
488pub enum ReadConsistency {
489    Local,
490    ReadBarrier,
491    AppliedIndex(LogIndex),
492}
493
494#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
495pub struct FetchLogRequest {
496    pub from_index: LogIndex,
497    pub max_entries: u32,
498}
499
500#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
501pub struct FetchLogResponse {
502    pub entries: Vec<LogEntry>,
503    pub last_index: LogIndex,
504}
505
506#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
507pub enum FetchLogError {
508    SnapshotRequired {
509        anchor: Box<RecoveryAnchor>,
510    },
511    Gap {
512        expected: LogIndex,
513        actual: Option<LogIndex>,
514    },
515    Decode {
516        message: String,
517    },
518    Transport {
519        message: String,
520    },
521    InvalidAnchor {
522        expected: LogHash,
523        actual: LogHash,
524    },
525    InvalidEntry {
526        index: LogIndex,
527        message: String,
528    },
529    ForeignIdentity {
530        index: LogIndex,
531    },
532    InvalidRequest {
533        message: String,
534    },
535}
536
537impl fmt::Display for FetchLogError {
538    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
539        match self {
540            Self::SnapshotRequired { anchor } => {
541                write!(
542                    f,
543                    "snapshot restore required at qlog anchor {}",
544                    anchor.compacted().index()
545                )
546            }
547            Self::Gap { expected, actual } => {
548                write!(f, "qlog gap: expected {expected}, got {actual:?}")
549            }
550            Self::Decode { message } => write!(f, "qlog response decode failed: {message}"),
551            Self::Transport { message } => write!(f, "qlog transport failed: {message}"),
552            Self::InvalidAnchor { .. } => write!(f, "qlog response has an invalid anchor"),
553            Self::InvalidEntry { index, message } => {
554                write!(f, "qlog entry {index} is invalid: {message}")
555            }
556            Self::ForeignIdentity { index } => {
557                write!(f, "qlog entry {index} has a foreign identity")
558            }
559            Self::InvalidRequest { message } => write!(f, "invalid qlog request: {message}"),
560        }
561    }
562}
563
564impl std::error::Error for FetchLogError {}
565
566pub trait LogPeer: Send + Sync {
567    fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError>;
568}
569
570#[derive(Clone, Debug, Eq, PartialEq)]
571pub struct InMemoryLogPeer {
572    entries: Vec<LogEntry>,
573    anchor: Option<RecoveryAnchor>,
574}
575
576impl InMemoryLogPeer {
577    pub fn new(mut entries: Vec<LogEntry>) -> Self {
578        entries.sort_by_key(|entry| entry.index);
579        Self {
580            entries,
581            anchor: None,
582        }
583    }
584
585    pub fn with_anchor(mut entries: Vec<LogEntry>, anchor: RecoveryAnchor) -> Self {
586        entries.sort_by_key(|entry| entry.index);
587        Self {
588            entries,
589            anchor: Some(anchor),
590        }
591    }
592}
593
594impl LogPeer for InMemoryLogPeer {
595    fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
596        if let Some(anchor) = &self.anchor {
597            if request.from_index <= anchor.compacted().index() {
598                return Err(FetchLogError::SnapshotRequired {
599                    anchor: Box::new(anchor.clone()),
600                });
601            }
602        }
603        let entries = self
604            .entries
605            .iter()
606            .filter(|entry| entry.index >= request.from_index)
607            .take(request.max_entries as usize)
608            .cloned()
609            .collect();
610        let last_index = self
611            .entries
612            .last()
613            .map(|entry| entry.index)
614            .or_else(|| {
615                self.anchor
616                    .as_ref()
617                    .map(|anchor| anchor.compacted().index())
618            })
619            .unwrap_or(0);
620        Ok(FetchLogResponse {
621            entries,
622            last_index,
623        })
624    }
625}
626
627#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
628struct RecorderWire<T> {
629    version: u16,
630    body: T,
631}
632
633#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
634#[serde(tag = "status", content = "body")]
635enum RecorderV2Result<T> {
636    Ok(T),
637    Rejected(RejectReason),
638    Error(String),
639}
640
641#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
642struct StoreCommandV2 {
643    cluster_id: String,
644    epoch: u64,
645    config_id: u64,
646    config_digest: LogHash,
647    command_hash: LogHash,
648    command: StoredCommand,
649}
650
651#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
652struct FetchCommandV2 {
653    cluster_id: String,
654    epoch: u64,
655    config_id: u64,
656    config_digest: LogHash,
657    command_hash: LogHash,
658}
659
660#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
661struct InspectProofV2 {
662    slot: u64,
663}
664
665#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
666struct InstallProofV2 {
667    proof: DecisionProof,
668    members: Vec<String>,
669}
670
671#[derive(Clone, Debug)]
672pub struct HttpRecorderClient {
673    base_url: String,
674    local_node_id: String,
675    peer_token: String,
676    recovery_generation: u64,
677    client: Arc<std::sync::OnceLock<reqwest::blocking::Client>>,
678}
679
680impl HttpRecorderClient {
681    pub fn new(
682        base_url: impl Into<String>,
683        local_node_id: impl Into<String>,
684        peer_token: impl Into<String>,
685    ) -> Result<Self, ConfigError> {
686        Self::new_with_recovery_generation(base_url, local_node_id, peer_token, 1)
687    }
688
689    pub fn new_with_recovery_generation(
690        base_url: impl Into<String>,
691        local_node_id: impl Into<String>,
692        peer_token: impl Into<String>,
693        recovery_generation: u64,
694    ) -> Result<Self, ConfigError> {
695        validate_recovery_generation(recovery_generation)?;
696        let peer = PeerConfig::new(local_node_id, base_url, peer_token)?;
697        Ok(Self {
698            base_url: peer.base_url,
699            local_node_id: peer.node_id,
700            peer_token: peer.token,
701            recovery_generation,
702            client: Arc::new(std::sync::OnceLock::new()),
703        })
704    }
705
706    pub fn with_recovery_generation(
707        mut self,
708        recovery_generation: u64,
709    ) -> Result<Self, ConfigError> {
710        validate_recovery_generation(recovery_generation)?;
711        self.recovery_generation = recovery_generation;
712        Ok(self)
713    }
714
715    fn url(&self, path: &str) -> String {
716        format!("{}{}", self.base_url, path)
717    }
718
719    fn client(&self) -> rhiza_quepaxa::Result<&reqwest::blocking::Client> {
720        if self.client.get().is_none() {
721            let client = reqwest::blocking::Client::builder()
722                .connect_timeout(HTTP_CONNECT_TIMEOUT)
723                .timeout(HTTP_REQUEST_TIMEOUT)
724                .build()
725                .map_err(|error| rhiza_quepaxa::Error::Io(error.to_string()))?;
726            let _ = self.client.set(client);
727        }
728        self.client
729            .get()
730            .ok_or_else(|| rhiza_quepaxa::Error::Io("HTTP client initialization failed".into()))
731    }
732
733    fn post_v2<T, U>(&self, path: &str, body: T) -> rhiza_quepaxa::Result<U>
734    where
735        T: serde::Serialize,
736        U: serde::de::DeserializeOwned,
737    {
738        self.post_v2_with_timeout(path, body, HTTP_REQUEST_TIMEOUT)
739    }
740
741    fn post_v2_with_timeout<T, U>(
742        &self,
743        path: &str,
744        body: T,
745        timeout: Duration,
746    ) -> rhiza_quepaxa::Result<U>
747    where
748        T: serde::Serialize,
749        U: serde::de::DeserializeOwned,
750    {
751        let response = self
752            .client()?
753            .post(self.url(path))
754            .timeout(timeout)
755            .header(VERSION_HEADER, RECORDER_PROTOCOL_VERSION)
756            .header(NODE_ID_HEADER, &self.local_node_id)
757            .header(
758                RECOVERY_GENERATION_HEADER,
759                self.recovery_generation.to_string(),
760            )
761            .bearer_auth(&self.peer_token)
762            .json(&RecorderWire {
763                version: RECORDER_WIRE_VERSION,
764                body,
765            })
766            .send()
767            .map_err(|error| rhiza_quepaxa::Error::Io(error.to_string()))?;
768        let status = response.status();
769        let wire = response
770            .json::<RecorderWire<RecorderV2Result<U>>>()
771            .map_err(|error| rhiza_quepaxa::Error::Decode(error.to_string()))?;
772        if wire.version != RECORDER_WIRE_VERSION {
773            return Err(rhiza_quepaxa::Error::Decode(
774                "recorder wire version mismatch".into(),
775            ));
776        }
777        match wire.body {
778            RecorderV2Result::Ok(value) if status.is_success() => Ok(value),
779            RecorderV2Result::Ok(_) => Err(rhiza_quepaxa::Error::Io(format!(
780                "recorder rpc returned HTTP {status}"
781            ))),
782            RecorderV2Result::Rejected(reason) => Err(rhiza_quepaxa::Error::Rejected(reason)),
783            RecorderV2Result::Error(message) => Err(rhiza_quepaxa::Error::Io(message)),
784        }
785    }
786}
787
788impl RecorderRpc for HttpRecorderClient {
789    fn recorder_id(&self) -> rhiza_quepaxa::Result<String> {
790        self.post_v2(RECORDER_IDENTITY_PATH, ())
791    }
792
793    fn store_command_for(
794        &self,
795        cluster_id: String,
796        epoch: u64,
797        config_id: u64,
798        config_digest: LogHash,
799        command_hash: LogHash,
800        command: StoredCommand,
801    ) -> rhiza_quepaxa::Result<()> {
802        self.post_v2(
803            RECORDER_STORE_COMMAND_PATH,
804            StoreCommandV2 {
805                cluster_id,
806                epoch,
807                config_id,
808                config_digest,
809                command_hash,
810                command,
811            },
812        )
813    }
814
815    fn fetch_command_for(
816        &self,
817        cluster_id: String,
818        epoch: u64,
819        config_id: u64,
820        config_digest: LogHash,
821        command_hash: LogHash,
822    ) -> rhiza_quepaxa::Result<Option<StoredCommand>> {
823        self.post_v2(
824            RECORDER_FETCH_COMMAND_PATH,
825            FetchCommandV2 {
826                cluster_id,
827                epoch,
828                config_id,
829                config_digest,
830                command_hash,
831            },
832        )
833    }
834
835    fn record(&self, request: RecordRequest) -> rhiza_quepaxa::Result<RecordSummary> {
836        self.post_v2_with_timeout(RECORDER_RECORD_PATH, request, QUORUM_RECORD_REQUEST_TIMEOUT)
837            .map_err(map_quorum_record_transport_error)
838    }
839
840    fn install_decision_proof(
841        &self,
842        proof: DecisionProof,
843        membership: &Membership,
844    ) -> rhiza_quepaxa::Result<()> {
845        self.post_v2(
846            RECORDER_INSTALL_PROOF_PATH,
847            InstallProofV2 {
848                proof,
849                members: membership.members().to_vec(),
850            },
851        )
852    }
853
854    fn inspect_decision_proof(&self, slot: u64) -> rhiza_quepaxa::Result<Option<DecisionProof>> {
855        self.post_v2(RECORDER_INSPECT_PROOF_PATH, InspectProofV2 { slot })
856    }
857
858    fn inspect_record_summary(&self, slot: u64) -> rhiza_quepaxa::Result<Option<RecordSummary>> {
859        self.post_v2(RECORDER_INSPECT_RECORD_PATH, InspectProofV2 { slot })
860    }
861
862    fn supports_context_read_fence(&self) -> bool {
863        true
864    }
865
866    fn observe_read_fence(
867        &self,
868        request: ReadFenceRequest,
869    ) -> rhiza_quepaxa::Result<ReadFenceObservation> {
870        self.post_v2_with_timeout(
871            RECORDER_READ_FENCE_PATH,
872            request,
873            READ_FENCE_REQUEST_TIMEOUT,
874        )
875    }
876}
877
878#[derive(Clone, Debug)]
879pub struct HttpLogPeer {
880    base_url: String,
881    local_node_id: String,
882    peer_token: String,
883    recovery_generation: u64,
884    client: Arc<std::sync::OnceLock<reqwest::blocking::Client>>,
885}
886
887impl HttpLogPeer {
888    pub fn new(
889        base_url: impl Into<String>,
890        local_node_id: impl Into<String>,
891        peer_token: impl Into<String>,
892    ) -> Result<Self, ConfigError> {
893        Self::new_with_recovery_generation(base_url, local_node_id, peer_token, 1)
894    }
895
896    pub fn new_with_recovery_generation(
897        base_url: impl Into<String>,
898        local_node_id: impl Into<String>,
899        peer_token: impl Into<String>,
900        recovery_generation: u64,
901    ) -> Result<Self, ConfigError> {
902        validate_recovery_generation(recovery_generation)?;
903        let peer = PeerConfig::new(local_node_id, base_url, peer_token)?;
904        Ok(Self {
905            base_url: peer.base_url,
906            local_node_id: peer.node_id,
907            peer_token: peer.token,
908            recovery_generation,
909            client: Arc::new(std::sync::OnceLock::new()),
910        })
911    }
912
913    pub fn with_recovery_generation(
914        mut self,
915        recovery_generation: u64,
916    ) -> Result<Self, ConfigError> {
917        validate_recovery_generation(recovery_generation)?;
918        self.recovery_generation = recovery_generation;
919        Ok(self)
920    }
921
922    fn url(&self, path: &str) -> String {
923        format!("{}{}", self.base_url, path)
924    }
925
926    fn client(&self) -> Result<&reqwest::blocking::Client, FetchLogError> {
927        if self.client.get().is_none() {
928            let client = reqwest::blocking::Client::builder()
929                .connect_timeout(HTTP_CONNECT_TIMEOUT)
930                .timeout(HTTP_REQUEST_TIMEOUT)
931                .build()
932                .map_err(|error| FetchLogError::Transport {
933                    message: error.to_string(),
934                })?;
935            let _ = self.client.set(client);
936        }
937        self.client.get().ok_or_else(|| FetchLogError::Transport {
938            message: "HTTP client initialization failed".into(),
939        })
940    }
941}
942
943impl LogPeer for HttpLogPeer {
944    fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
945        let response = self
946            .client()?
947            .post(self.url(LOG_FETCH_PATH))
948            .header(VERSION_HEADER, PROTOCOL_VERSION)
949            .header(NODE_ID_HEADER, &self.local_node_id)
950            .header(
951                RECOVERY_GENERATION_HEADER,
952                self.recovery_generation.to_string(),
953            )
954            .bearer_auth(&self.peer_token)
955            .json(&request)
956            .send()
957            .map_err(|err| FetchLogError::Transport {
958                message: err.to_string(),
959            })?;
960        let status = response.status();
961        match response
962            .json::<FetchLogHttpResponse>()
963            .map_err(|err| FetchLogError::Decode {
964                message: err.to_string(),
965            })? {
966            FetchLogHttpResponse::Fetched(response) if status.is_success() => Ok(response),
967            FetchLogHttpResponse::Fetched(_) => Err(FetchLogError::Transport {
968                message: format!("log rpc returned HTTP {status}"),
969            }),
970            FetchLogHttpResponse::Failed(error) => Err(error),
971        }
972    }
973}
974
975#[derive(Clone)]
976struct RecorderRouteState<R> {
977    recorder: R,
978    peers: Vec<PeerConfig>,
979}
980
981#[derive(Clone)]
982struct AuthenticatedPeer(String);
983
984#[derive(Clone)]
985struct LogRouteState<P> {
986    peer: P,
987}
988
989#[derive(Clone)]
990struct NodeRouteState {
991    runtime: Arc<NodeRuntime>,
992    coordinator: Option<Arc<CheckpointCoordinator>>,
993    write_operations: Arc<tokio::sync::Mutex<HashMap<String, WriteOperation>>>,
994    writer: tokio::sync::mpsc::Sender<QueuedWrite>,
995}
996
997#[derive(Clone)]
998struct WriteOperation {
999    payload: Vec<u8>,
1000    result: tokio::sync::watch::Receiver<Option<WriteOperationResult>>,
1001}
1002
1003#[derive(Clone)]
1004enum WriteOperationResult {
1005    Runtime(Result<ClientWriteResponse, NodeError>),
1006    DurabilityUnavailable,
1007}
1008
1009#[derive(Clone)]
1010enum ClientWriteResponse {
1011    #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1012    Unavailable,
1013    #[cfg(feature = "sql")]
1014    KeyValue(WriteResponse),
1015    #[cfg(feature = "sql")]
1016    Sql(SqlExecuteResponse),
1017    #[cfg(feature = "graph")]
1018    Graph(GraphMutationOutcome),
1019    #[cfg(feature = "kv")]
1020    Kv(KvMutationOutcome),
1021}
1022
1023struct QueuedWrite {
1024    request_id: String,
1025    payload: Vec<u8>,
1026    operation: QueuedOperation,
1027    permit: Arc<tokio::sync::OwnedSemaphorePermit>,
1028    sender: tokio::sync::watch::Sender<Option<WriteOperationResult>>,
1029}
1030
1031enum QueuedOperation {
1032    #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1033    Unavailable,
1034    #[cfg(feature = "sql")]
1035    KeyValue { key: String, value: String },
1036    #[cfg(feature = "sql")]
1037    Sql(SqlCommand),
1038    #[cfg(feature = "graph")]
1039    Graph(GraphCommandV1),
1040    #[cfg(feature = "kv")]
1041    Kv(KvCommandV1),
1042}
1043
1044struct RuntimeBatchMember {
1045    #[cfg(feature = "sql")]
1046    request_id: String,
1047    payload: Vec<u8>,
1048    operation: QueuedOperation,
1049}
1050
1051#[cfg(feature = "sql")]
1052type SqlGroupCommitResult = Result<Vec<Result<ClientWriteResponse, NodeError>>, NodeError>;
1053
1054#[cfg(feature = "sql")]
1055struct SqlGroupCommitJob {
1056    member_count: usize,
1057    encoded_bytes: usize,
1058    members: Mutex<Option<Vec<RuntimeBatchMember>>>,
1059    result: Mutex<Option<SqlGroupCommitResult>>,
1060    changed: Condvar,
1061}
1062
1063#[cfg(feature = "sql")]
1064impl SqlGroupCommitJob {
1065    fn new(members: Vec<RuntimeBatchMember>) -> Self {
1066        Self {
1067            member_count: members.len(),
1068            encoded_bytes: members.iter().fold(0_usize, |bytes, member| {
1069                bytes.saturating_add(member.payload.len())
1070            }),
1071            members: Mutex::new(Some(members)),
1072            result: Mutex::new(None),
1073            changed: Condvar::new(),
1074        }
1075    }
1076
1077    fn take_members(&self) -> Result<Vec<RuntimeBatchMember>, NodeError> {
1078        self.members
1079            .lock()
1080            .unwrap_or_else(std::sync::PoisonError::into_inner)
1081            .take()
1082            .ok_or_else(|| {
1083                NodeError::Invariant("SQL group commit job members were already consumed".into())
1084            })
1085    }
1086
1087    fn publish(&self, result: SqlGroupCommitResult) {
1088        let mut slot = self
1089            .result
1090            .lock()
1091            .unwrap_or_else(std::sync::PoisonError::into_inner);
1092        if slot.is_none() {
1093            *slot = Some(result);
1094            self.changed.notify_all();
1095        }
1096    }
1097
1098    fn wait(&self, cancelled: &AtomicBool) -> SqlGroupCommitResult {
1099        let mut result = self
1100            .result
1101            .lock()
1102            .unwrap_or_else(std::sync::PoisonError::into_inner);
1103        loop {
1104            if let Some(result) = result.take() {
1105                return result;
1106            }
1107            if cancelled.load(Ordering::Acquire) {
1108                return Err(NodeError::Unavailable(
1109                    "SQL group commit cancelled during shutdown".into(),
1110                ));
1111            }
1112            result = self
1113                .changed
1114                .wait(result)
1115                .unwrap_or_else(std::sync::PoisonError::into_inner);
1116        }
1117    }
1118}
1119
1120#[cfg(feature = "sql")]
1121struct SqlGroupCommitQueue {
1122    capacity: usize,
1123    state: Mutex<SqlGroupCommitQueueState>,
1124    changed: Condvar,
1125}
1126
1127#[cfg(feature = "sql")]
1128#[derive(Default)]
1129struct SqlGroupCommitQueueState {
1130    pending: VecDeque<Arc<SqlGroupCommitJob>>,
1131    pending_encoded_bytes: usize,
1132    leader_active: bool,
1133}
1134
1135#[cfg(feature = "sql")]
1136impl SqlGroupCommitQueue {
1137    fn new(capacity: usize) -> Self {
1138        Self {
1139            capacity,
1140            state: Mutex::new(SqlGroupCommitQueueState::default()),
1141            changed: Condvar::new(),
1142        }
1143    }
1144
1145    fn enqueue(
1146        &self,
1147        members: Vec<RuntimeBatchMember>,
1148        cancelled: &AtomicBool,
1149    ) -> Result<(Arc<SqlGroupCommitJob>, bool), NodeError> {
1150        if cancelled.load(Ordering::Acquire) {
1151            return Err(NodeError::Unavailable(
1152                "SQL group commit is unavailable during shutdown".into(),
1153            ));
1154        }
1155        let job = Arc::new(SqlGroupCommitJob::new(members));
1156        if job.encoded_bytes > MAX_COMMAND_BYTES {
1157            return Err(NodeError::ResourceExhausted(format!(
1158                "SQL group commit call exceeds {MAX_COMMAND_BYTES} encoded bytes"
1159            )));
1160        }
1161        let mut state = self
1162            .state
1163            .lock()
1164            .unwrap_or_else(std::sync::PoisonError::into_inner);
1165        if state.pending.len() >= self.capacity {
1166            return Err(NodeError::ResourceExhausted(format!(
1167                "SQL group commit queue is full (capacity {})",
1168                self.capacity
1169            )));
1170        }
1171        let pending_encoded_bytes = state
1172            .pending_encoded_bytes
1173            .saturating_add(job.encoded_bytes);
1174        if pending_encoded_bytes > MAX_SQL_GROUP_COMMIT_PENDING_BYTES {
1175            return Err(NodeError::ResourceExhausted(format!(
1176                "SQL group commit queue exceeds {MAX_SQL_GROUP_COMMIT_PENDING_BYTES} pending encoded bytes"
1177            )));
1178        }
1179        state.pending_encoded_bytes = pending_encoded_bytes;
1180        state.pending.push_back(Arc::clone(&job));
1181        self.changed.notify_all();
1182        let leader = !state.leader_active;
1183        if leader {
1184            state.leader_active = true;
1185        }
1186        Ok((job, leader))
1187    }
1188
1189    fn drain_next_group(&self) -> Option<Vec<Arc<SqlGroupCommitJob>>> {
1190        let mut state = self
1191            .state
1192            .lock()
1193            .unwrap_or_else(std::sync::PoisonError::into_inner);
1194        if state.pending.is_empty() {
1195            state.leader_active = false;
1196            return None;
1197        }
1198        let mut member_count = 0_usize;
1199        let mut encoded_bytes = 0_usize;
1200        let mut jobs = Vec::new();
1201        while let Some(job) = state.pending.front() {
1202            let next_count = member_count.saturating_add(job.member_count);
1203            let next_encoded_bytes = encoded_bytes.saturating_add(job.encoded_bytes);
1204            if !jobs.is_empty()
1205                && (next_count > MAX_SQL_WRITE_BATCH_MEMBERS
1206                    || next_encoded_bytes > MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES)
1207            {
1208                break;
1209            }
1210            let job = state.pending.pop_front().expect("front job exists");
1211            member_count = next_count;
1212            encoded_bytes = next_encoded_bytes;
1213            state.pending_encoded_bytes = state
1214                .pending_encoded_bytes
1215                .checked_sub(job.encoded_bytes)
1216                .expect("queued SQL byte reservation covers every pending job");
1217            jobs.push(job);
1218        }
1219        Some(jobs)
1220    }
1221
1222    fn collect_until_full_or_timeout(&self, timeout: Duration) -> bool {
1223        let mut state = self
1224            .state
1225            .lock()
1226            .unwrap_or_else(std::sync::PoisonError::into_inner);
1227        if state.pending.is_empty() {
1228            state.leader_active = false;
1229            return false;
1230        }
1231        let hard_deadline = Instant::now() + timeout.saturating_mul(4);
1232        loop {
1233            let member_count = state
1234                .pending
1235                .iter()
1236                .fold(0_usize, |count, job| count.saturating_add(job.member_count));
1237            if member_count >= MAX_SQL_WRITE_BATCH_MEMBERS
1238                || state.pending_encoded_bytes >= MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES
1239            {
1240                return true;
1241            }
1242            let remaining = hard_deadline.saturating_duration_since(Instant::now());
1243            if remaining.is_zero() {
1244                return true;
1245            }
1246            let observed_calls = state.pending.len();
1247            let (next, quiet) = self
1248                .changed
1249                .wait_timeout_while(state, timeout.min(remaining), |state| {
1250                    state.pending.len() == observed_calls
1251                        && state
1252                            .pending
1253                            .iter()
1254                            .fold(0_usize, |count, job| count.saturating_add(job.member_count))
1255                            < MAX_SQL_WRITE_BATCH_MEMBERS
1256                        && state.pending_encoded_bytes < MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES
1257                })
1258                .unwrap_or_else(std::sync::PoisonError::into_inner);
1259            state = next;
1260            if quiet.timed_out() {
1261                return true;
1262            }
1263        }
1264    }
1265
1266    fn fail_pending(&self, error: NodeError) {
1267        let jobs = {
1268            let mut state = self
1269                .state
1270                .lock()
1271                .unwrap_or_else(std::sync::PoisonError::into_inner);
1272            state.leader_active = false;
1273            state.pending_encoded_bytes = 0;
1274            state.pending.drain(..).collect::<Vec<_>>()
1275        };
1276        for job in jobs {
1277            job.publish(Err(error.clone()));
1278        }
1279    }
1280
1281    #[cfg(test)]
1282    fn wait_for_pending_calls(&self, expected: usize, timeout: Duration) {
1283        let state = self
1284            .state
1285            .lock()
1286            .unwrap_or_else(std::sync::PoisonError::into_inner);
1287        let (state, timed_out) = self
1288            .changed
1289            .wait_timeout_while(state, timeout, |state| state.pending.len() != expected)
1290            .unwrap_or_else(std::sync::PoisonError::into_inner);
1291        assert!(
1292            !timed_out.timed_out(),
1293            "expected {expected} pending SQL group commit calls, got {}",
1294            state.pending.len()
1295        );
1296    }
1297}
1298
1299#[cfg(feature = "kv")]
1300type KvGroupCommitResult = Result<Vec<Result<ClientWriteResponse, NodeError>>, NodeError>;
1301
1302#[cfg(feature = "kv")]
1303struct KvGroupCommitJob {
1304    member_count: usize,
1305    encoded_bytes: usize,
1306    members: Mutex<Option<Vec<RuntimeBatchMember>>>,
1307    result: Mutex<Option<KvGroupCommitResult>>,
1308    changed: Condvar,
1309}
1310
1311#[cfg(feature = "kv")]
1312impl KvGroupCommitJob {
1313    fn new(members: Vec<RuntimeBatchMember>) -> Self {
1314        Self {
1315            member_count: members.len(),
1316            encoded_bytes: members.iter().fold(0_usize, |bytes, member| {
1317                bytes.saturating_add(member.payload.len())
1318            }),
1319            members: Mutex::new(Some(members)),
1320            result: Mutex::new(None),
1321            changed: Condvar::new(),
1322        }
1323    }
1324
1325    fn take_members(&self) -> Result<Vec<RuntimeBatchMember>, NodeError> {
1326        self.members
1327            .lock()
1328            .unwrap_or_else(std::sync::PoisonError::into_inner)
1329            .take()
1330            .ok_or_else(|| {
1331                NodeError::Invariant("KV group commit job members were already consumed".into())
1332            })
1333    }
1334
1335    fn publish(&self, result: KvGroupCommitResult) {
1336        let mut slot = self
1337            .result
1338            .lock()
1339            .unwrap_or_else(std::sync::PoisonError::into_inner);
1340        if slot.is_none() {
1341            *slot = Some(result);
1342            self.changed.notify_all();
1343        }
1344    }
1345
1346    fn wait(&self, cancelled: &AtomicBool) -> KvGroupCommitResult {
1347        let mut result = self
1348            .result
1349            .lock()
1350            .unwrap_or_else(std::sync::PoisonError::into_inner);
1351        loop {
1352            if let Some(result) = result.take() {
1353                return result;
1354            }
1355            if cancelled.load(Ordering::Acquire) {
1356                return Err(NodeError::Unavailable(
1357                    "KV group commit cancelled during shutdown".into(),
1358                ));
1359            }
1360            result = self
1361                .changed
1362                .wait(result)
1363                .unwrap_or_else(std::sync::PoisonError::into_inner);
1364        }
1365    }
1366}
1367
1368#[cfg(feature = "kv")]
1369struct KvGroupCommitQueue {
1370    state: Mutex<KvGroupCommitQueueState>,
1371    changed: Condvar,
1372}
1373
1374#[cfg(feature = "kv")]
1375#[derive(Default)]
1376struct KvGroupCommitQueueState {
1377    pending: VecDeque<Arc<KvGroupCommitJob>>,
1378    pending_encoded_bytes: usize,
1379    leader_active: bool,
1380}
1381
1382#[cfg(feature = "kv")]
1383impl KvGroupCommitQueue {
1384    fn new() -> Self {
1385        Self {
1386            state: Mutex::new(KvGroupCommitQueueState::default()),
1387            changed: Condvar::new(),
1388        }
1389    }
1390
1391    fn enqueue(
1392        &self,
1393        members: Vec<RuntimeBatchMember>,
1394        cancelled: &AtomicBool,
1395    ) -> Result<(Arc<KvGroupCommitJob>, bool), NodeError> {
1396        if cancelled.load(Ordering::Acquire) {
1397            return Err(NodeError::Unavailable(
1398                "KV group commit is unavailable during shutdown".into(),
1399            ));
1400        }
1401        let job = Arc::new(KvGroupCommitJob::new(members));
1402        if job.member_count == 0 || job.member_count > MAX_KV_BATCH_MEMBERS {
1403            return Err(NodeError::InvalidRequest(format!(
1404                "KV group commit call must contain 1..={MAX_KV_BATCH_MEMBERS} members"
1405            )));
1406        }
1407        if job.encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES {
1408            return Err(NodeError::ResourceExhausted(format!(
1409                "KV group commit call exceeds {MAX_KV_GROUP_COMMIT_PENDING_BYTES} encoded bytes"
1410            )));
1411        }
1412        let mut state = self
1413            .state
1414            .lock()
1415            .unwrap_or_else(std::sync::PoisonError::into_inner);
1416        if state.pending.len() >= KV_GROUP_COMMIT_QUEUE_CAPACITY {
1417            return Err(NodeError::ResourceExhausted(format!(
1418                "KV group commit queue is full (capacity {KV_GROUP_COMMIT_QUEUE_CAPACITY})"
1419            )));
1420        }
1421        let pending_encoded_bytes = state
1422            .pending_encoded_bytes
1423            .saturating_add(job.encoded_bytes);
1424        if pending_encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES {
1425            return Err(NodeError::ResourceExhausted(format!(
1426                "KV group commit queue exceeds {MAX_KV_GROUP_COMMIT_PENDING_BYTES} pending encoded bytes"
1427            )));
1428        }
1429        state.pending_encoded_bytes = pending_encoded_bytes;
1430        state.pending.push_back(Arc::clone(&job));
1431        self.changed.notify_all();
1432        let leader = !state.leader_active;
1433        if leader {
1434            state.leader_active = true;
1435        }
1436        Ok((job, leader))
1437    }
1438
1439    fn drain_next_group(&self) -> Option<Vec<Arc<KvGroupCommitJob>>> {
1440        let mut state = self
1441            .state
1442            .lock()
1443            .unwrap_or_else(std::sync::PoisonError::into_inner);
1444        if state.pending.is_empty() {
1445            state.leader_active = false;
1446            return None;
1447        }
1448        let mut member_count = 0_usize;
1449        let mut encoded_bytes = 0_usize;
1450        let mut jobs = Vec::new();
1451        while let Some(job) = state.pending.front() {
1452            let next_count = member_count.saturating_add(job.member_count);
1453            let next_encoded_bytes = encoded_bytes.saturating_add(job.encoded_bytes);
1454            if !jobs.is_empty()
1455                && (next_count > MAX_KV_GROUP_COMMIT_MEMBERS
1456                    || next_encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES)
1457            {
1458                break;
1459            }
1460            let job = state.pending.pop_front().expect("front job exists");
1461            member_count = next_count;
1462            encoded_bytes = next_encoded_bytes;
1463            state.pending_encoded_bytes = state
1464                .pending_encoded_bytes
1465                .checked_sub(job.encoded_bytes)
1466                .expect("queued KV byte reservation covers every pending job");
1467            jobs.push(job);
1468        }
1469        Some(jobs)
1470    }
1471
1472    fn collect_until_full_or_timeout(&self, timeout: Duration) -> bool {
1473        let mut state = self
1474            .state
1475            .lock()
1476            .unwrap_or_else(std::sync::PoisonError::into_inner);
1477        if state.pending.is_empty() {
1478            state.leader_active = false;
1479            return false;
1480        }
1481        let hard_deadline = Instant::now() + timeout.saturating_mul(4);
1482        loop {
1483            let member_count = state
1484                .pending
1485                .iter()
1486                .fold(0_usize, |count, job| count.saturating_add(job.member_count));
1487            if member_count >= MAX_KV_GROUP_COMMIT_MEMBERS
1488                || state.pending_encoded_bytes >= MAX_KV_GROUP_COMMIT_PENDING_BYTES
1489            {
1490                return true;
1491            }
1492            let remaining = hard_deadline.saturating_duration_since(Instant::now());
1493            if remaining.is_zero() {
1494                return true;
1495            }
1496            let observed_calls = state.pending.len();
1497            let (next, quiet) = self
1498                .changed
1499                .wait_timeout_while(state, timeout.min(remaining), |state| {
1500                    state.pending.len() == observed_calls
1501                        && state
1502                            .pending
1503                            .iter()
1504                            .fold(0_usize, |count, job| count.saturating_add(job.member_count))
1505                            < MAX_KV_GROUP_COMMIT_MEMBERS
1506                        && state.pending_encoded_bytes < MAX_KV_GROUP_COMMIT_PENDING_BYTES
1507                })
1508                .unwrap_or_else(std::sync::PoisonError::into_inner);
1509            state = next;
1510            if quiet.timed_out() {
1511                return true;
1512            }
1513        }
1514    }
1515
1516    fn fail_pending(&self, error: NodeError) {
1517        let jobs = {
1518            let mut state = self
1519                .state
1520                .lock()
1521                .unwrap_or_else(std::sync::PoisonError::into_inner);
1522            state.leader_active = false;
1523            state.pending_encoded_bytes = 0;
1524            state.pending.drain(..).collect::<Vec<_>>()
1525        };
1526        for job in jobs {
1527            job.publish(Err(error.clone()));
1528        }
1529    }
1530
1531    #[cfg(test)]
1532    fn wait_for_pending_calls(&self, expected: usize, timeout: Duration) {
1533        let state = self
1534            .state
1535            .lock()
1536            .unwrap_or_else(std::sync::PoisonError::into_inner);
1537        let (state, timed_out) = self
1538            .changed
1539            .wait_timeout_while(state, timeout, |state| state.pending.len() != expected)
1540            .unwrap_or_else(std::sync::PoisonError::into_inner);
1541        assert!(
1542            !timed_out.timed_out(),
1543            "expected {expected} pending KV group commit calls, got {}",
1544            state.pending.len()
1545        );
1546    }
1547}
1548
1549#[cfg(any(feature = "graph", feature = "kv"))]
1550fn classify_pending_request(
1551    canonical_by_request: &mut HashMap<String, usize>,
1552    members: &[RuntimeBatchMember],
1553    index: usize,
1554    request_id: &str,
1555) -> Result<Option<usize>, NodeError> {
1556    let Some(&canonical) = canonical_by_request.get(request_id) else {
1557        canonical_by_request.insert(request_id.to_owned(), index);
1558        return Ok(None);
1559    };
1560    if members[canonical].payload == members[index].payload {
1561        Ok(Some(canonical))
1562    } else {
1563        Err(NodeError::InvalidRequest(format!(
1564            "request id {request_id:?} was reused with another command in the same writer batch"
1565        )))
1566    }
1567}
1568
1569impl ClientWriteResponse {
1570    fn applied_index(&self) -> LogIndex {
1571        match self {
1572            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1573            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
1574            #[cfg(feature = "sql")]
1575            Self::KeyValue(response) => response.applied_index,
1576            #[cfg(feature = "sql")]
1577            Self::Sql(response) => response.applied_index,
1578            #[cfg(feature = "graph")]
1579            Self::Graph(response) => response.applied_index(),
1580            #[cfg(feature = "kv")]
1581            Self::Kv(response) => response.applied_index(),
1582        }
1583    }
1584}
1585
1586#[cfg(feature = "sql")]
1587#[derive(Clone)]
1588pub struct NodeService {
1589    runtime: Arc<NodeRuntime>,
1590    coordinator: Option<Arc<CheckpointCoordinator>>,
1591    #[cfg(feature = "sql")]
1592    sql_reads_in_flight: Arc<AtomicUsize>,
1593}
1594
1595#[cfg(feature = "sql")]
1596struct SqlReadActivity {
1597    active: Arc<AtomicUsize>,
1598}
1599
1600#[cfg(feature = "sql")]
1601impl SqlReadActivity {
1602    fn enter(active: &Arc<AtomicUsize>) -> (Self, bool) {
1603        let previous = active.fetch_add(1, Ordering::Relaxed);
1604        debug_assert_ne!(previous, usize::MAX);
1605        (
1606            Self {
1607                active: active.clone(),
1608            },
1609            previous == 0,
1610        )
1611    }
1612}
1613
1614#[cfg(feature = "sql")]
1615impl Drop for SqlReadActivity {
1616    fn drop(&mut self) {
1617        let previous = self.active.fetch_sub(1, Ordering::Relaxed);
1618        debug_assert!(previous > 0);
1619    }
1620}
1621
1622#[cfg(feature = "sql")]
1623impl NodeService {
1624    pub fn new(runtime: Arc<NodeRuntime>, coordinator: Option<Arc<CheckpointCoordinator>>) -> Self {
1625        Self {
1626            runtime,
1627            coordinator,
1628            #[cfg(feature = "sql")]
1629            sql_reads_in_flight: Arc::new(AtomicUsize::new(0)),
1630        }
1631    }
1632
1633    #[cfg(feature = "sql")]
1634    pub async fn put(
1635        &self,
1636        request_id: &str,
1637        key: &str,
1638        value: &str,
1639    ) -> Result<WriteResponse, NodeError> {
1640        self.write(WriteRequest {
1641            request_id: request_id.into(),
1642            key: key.into(),
1643            value: value.into(),
1644        })
1645        .await
1646    }
1647
1648    #[cfg(feature = "sql")]
1649    pub async fn write(&self, request: WriteRequest) -> Result<WriteResponse, NodeError> {
1650        self.write_allowed()?;
1651        let runtime = self.runtime.clone();
1652        let response = tokio::task::spawn_blocking(move || {
1653            runtime.write(&request.request_id, &request.key, &request.value)
1654        })
1655        .await
1656        .map_err(node_service_task_error)??;
1657        self.confirm_committed(response.applied_index).await?;
1658        Ok(response)
1659    }
1660
1661    #[cfg(feature = "sql")]
1662    pub async fn execute_sql(&self, command: SqlCommand) -> Result<SqlExecuteResponse, NodeError> {
1663        self.write_allowed()?;
1664        let runtime = self.runtime.clone();
1665        let response =
1666            tokio::task::spawn_blocking(move || runtime.execute_sql_with_results(command))
1667                .await
1668                .map_err(node_service_task_error)??;
1669        self.confirm_committed(response.applied_index).await?;
1670        Ok(response)
1671    }
1672
1673    #[cfg(feature = "sql")]
1674    pub async fn read(
1675        &self,
1676        key: &str,
1677        consistency: ReadConsistency,
1678    ) -> Result<ReadResponse, NodeError> {
1679        let runtime = self.runtime.clone();
1680        let key = key.to_owned();
1681        self.run_sql_read_operation(consistency, move || runtime.read(&key, consistency))
1682            .await
1683            .map_err(node_service_task_error)?
1684    }
1685
1686    #[cfg(feature = "sql")]
1687    pub async fn query(
1688        &self,
1689        statement: SqlStatement,
1690        consistency: ReadConsistency,
1691        max_rows: u32,
1692    ) -> Result<SqlQueryResponse, NodeError> {
1693        let runtime = self.runtime.clone();
1694        self.run_sql_read_operation(consistency, move || {
1695            runtime.query_sql(&statement, consistency, max_rows)
1696        })
1697        .await
1698        .map_err(node_service_task_error)?
1699    }
1700
1701    #[cfg(feature = "sql")]
1702    async fn run_sql_read_operation<F, T>(
1703        &self,
1704        consistency: ReadConsistency,
1705        operation: F,
1706    ) -> Result<T, tokio::task::JoinError>
1707    where
1708        F: FnOnce() -> T + Send + 'static,
1709        T: Send + 'static,
1710    {
1711        if consistency == ReadConsistency::ReadBarrier {
1712            return run_read_operation(consistency, operation).await;
1713        }
1714        let (activity, sole_read) = SqlReadActivity::enter(&self.sql_reads_in_flight);
1715        let operation = move || {
1716            let _activity = activity;
1717            operation()
1718        };
1719        if sole_read {
1720            run_read_operation(consistency, operation).await
1721        } else {
1722            tokio::task::spawn_blocking(operation).await
1723        }
1724    }
1725
1726    #[cfg(feature = "sql")]
1727    fn write_allowed(&self) -> Result<(), NodeError> {
1728        self.coordinator
1729            .as_ref()
1730            .map_or(Ok(()), |coordinator| coordinator.write_allowed())
1731            .map_err(|error| NodeError::Unavailable(error.to_string()))
1732    }
1733
1734    #[cfg(feature = "sql")]
1735    async fn confirm_committed(&self, index: LogIndex) -> Result<(), NodeError> {
1736        confirm_write_durability(self.runtime.as_ref(), self.coordinator.as_deref(), index)
1737            .await
1738            .map_err(|error| NodeError::Unavailable(error.to_string()))
1739    }
1740}
1741
1742#[cfg(feature = "sql")]
1743fn node_service_task_error(error: tokio::task::JoinError) -> NodeError {
1744    NodeError::Fatal(format!("node service task failed: {error}"))
1745}
1746
1747#[doc(hidden)]
1748pub async fn run_read_operation<F, T>(
1749    consistency: ReadConsistency,
1750    operation: F,
1751) -> Result<T, tokio::task::JoinError>
1752where
1753    F: FnOnce() -> T + Send + 'static,
1754    T: Send + 'static,
1755{
1756    if consistency != ReadConsistency::ReadBarrier
1757        && matches!(
1758            tokio::runtime::Handle::current().runtime_flavor(),
1759            tokio::runtime::RuntimeFlavor::MultiThread
1760        )
1761    {
1762        Ok(tokio::task::block_in_place(operation))
1763    } else {
1764        tokio::task::spawn_blocking(operation).await
1765    }
1766}
1767
1768#[derive(Clone)]
1769struct PeerGateState {
1770    peers: Vec<PeerConfig>,
1771    recovery_generation: u64,
1772    protocol_version: &'static str,
1773    slots: Arc<tokio::sync::Semaphore>,
1774}
1775
1776#[derive(Clone)]
1777struct ClientGateState {
1778    runtime: Arc<NodeRuntime>,
1779    slots: Arc<tokio::sync::Semaphore>,
1780    coordinator: Option<Arc<CheckpointCoordinator>>,
1781}
1782
1783#[derive(Clone)]
1784struct RuntimeLogPeer {
1785    runtime: Arc<NodeRuntime>,
1786}
1787
1788impl LogPeer for RuntimeLogPeer {
1789    fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
1790        self.runtime.fetch_log(request)
1791    }
1792}
1793
1794fn fetch_runtime_log(
1795    runtime: &NodeRuntime,
1796    request: FetchLogRequest,
1797) -> Result<FetchLogResponse, FetchLogError> {
1798    if request.from_index == 0 || request.max_entries > MAX_FETCH_ENTRIES {
1799        return Err(FetchLogError::InvalidRequest {
1800            message: "invalid fetch bounds".into(),
1801        });
1802    }
1803    let state = runtime
1804        .log_store
1805        .logical_state()
1806        .map_err(|error| FetchLogError::Transport {
1807            message: error.to_string(),
1808        })?;
1809    if let Some(anchor) = state.anchor {
1810        if request.from_index <= anchor.compacted().index() {
1811            return Err(FetchLogError::SnapshotRequired {
1812                anchor: Box::new(anchor),
1813            });
1814        }
1815    }
1816    let last_index = state.tip.map_or(0, |tip| tip.index());
1817    if request.max_entries == 0 || request.from_index > last_index {
1818        return Ok(FetchLogResponse {
1819            entries: Vec::new(),
1820            last_index,
1821        });
1822    }
1823    let end = request
1824        .from_index
1825        .saturating_add(u64::from(request.max_entries) - 1)
1826        .min(last_index);
1827    let range = IndexRange::new(request.from_index, end).map_err(|error| {
1828        FetchLogError::InvalidRequest {
1829            message: error.to_string(),
1830        }
1831    })?;
1832    let entries =
1833        runtime
1834            .log_store
1835            .read_range(range)
1836            .map_err(|error| FetchLogError::Transport {
1837                message: error.to_string(),
1838            })?;
1839    Ok(FetchLogResponse {
1840        entries,
1841        last_index,
1842    })
1843}
1844
1845pub fn recorder_router<R, P>(recorder: R, peers: P) -> Router
1846where
1847    R: RecorderRpc + Clone + Send + Sync + 'static,
1848    P: Into<Vec<PeerConfig>>,
1849{
1850    recorder_router_for_generation(recorder, peers, 1)
1851}
1852
1853pub fn recorder_router_for_generation<R, P>(
1854    recorder: R,
1855    peers: P,
1856    recovery_generation: u64,
1857) -> Router
1858where
1859    R: RecorderRpc + Clone + Send + Sync + 'static,
1860    P: Into<Vec<PeerConfig>>,
1861{
1862    recorder_routes(
1863        recorder,
1864        peers.into(),
1865        recovery_generation,
1866        Arc::new(tokio::sync::Semaphore::new(DEFAULT_PEER_CONCURRENCY)),
1867    )
1868    .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
1869}
1870
1871pub fn log_peer_router<P, C>(peer: P, peers: C) -> Router
1872where
1873    P: LogPeer + Clone + Send + Sync + 'static,
1874    C: Into<Vec<PeerConfig>>,
1875{
1876    log_peer_router_for_generation(peer, peers, 1)
1877}
1878
1879pub fn log_peer_router_for_generation<P, C>(peer: P, peers: C, recovery_generation: u64) -> Router
1880where
1881    P: LogPeer + Clone + Send + Sync + 'static,
1882    C: Into<Vec<PeerConfig>>,
1883{
1884    log_routes(
1885        peer,
1886        peers.into(),
1887        recovery_generation,
1888        Arc::new(tokio::sync::Semaphore::new(DEFAULT_PEER_CONCURRENCY)),
1889    )
1890    .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
1891}
1892
1893pub fn node_rpc_router<R, P, C>(recorder: R, peer: P, peers: C) -> Router
1894where
1895    R: RecorderRpc + Clone + Send + Sync + 'static,
1896    P: LogPeer + Clone + Send + Sync + 'static,
1897    C: Into<Vec<PeerConfig>>,
1898{
1899    node_rpc_router_for_generation(recorder, peer, peers, 1)
1900}
1901
1902pub fn node_rpc_router_for_generation<R, P, C>(
1903    recorder: R,
1904    peer: P,
1905    peers: C,
1906    recovery_generation: u64,
1907) -> Router
1908where
1909    R: RecorderRpc + Clone + Send + Sync + 'static,
1910    P: LogPeer + Clone + Send + Sync + 'static,
1911    C: Into<Vec<PeerConfig>>,
1912{
1913    node_rpc_router_with_limits_for_generation(
1914        recorder,
1915        peer,
1916        peers,
1917        DEFAULT_PEER_CONCURRENCY,
1918        DEFAULT_PEER_CONCURRENCY,
1919        recovery_generation,
1920    )
1921}
1922
1923pub fn node_rpc_router_with_limits<R, P, C>(
1924    recorder: R,
1925    peer: P,
1926    peers: C,
1927    recorder_concurrency: usize,
1928    log_concurrency: usize,
1929) -> Router
1930where
1931    R: RecorderRpc + Clone + Send + Sync + 'static,
1932    P: LogPeer + Clone + Send + Sync + 'static,
1933    C: Into<Vec<PeerConfig>>,
1934{
1935    node_rpc_router_with_limits_for_generation(
1936        recorder,
1937        peer,
1938        peers,
1939        recorder_concurrency,
1940        log_concurrency,
1941        1,
1942    )
1943}
1944
1945pub fn node_rpc_router_with_limits_for_generation<R, P, C>(
1946    recorder: R,
1947    peer: P,
1948    peers: C,
1949    recorder_concurrency: usize,
1950    log_concurrency: usize,
1951    recovery_generation: u64,
1952) -> Router
1953where
1954    R: RecorderRpc + Clone + Send + Sync + 'static,
1955    P: LogPeer + Clone + Send + Sync + 'static,
1956    C: Into<Vec<PeerConfig>>,
1957{
1958    let peers = peers.into();
1959    let recorder_slots = Arc::new(tokio::sync::Semaphore::new(recorder_concurrency));
1960    let log_slots = Arc::new(tokio::sync::Semaphore::new(log_concurrency));
1961    recorder_routes(recorder, peers.clone(), recovery_generation, recorder_slots)
1962        .merge(log_routes(peer, peers, recovery_generation, log_slots))
1963        .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
1964}
1965
1966pub fn node_router<R>(runtime: Arc<NodeRuntime>, recorder: R) -> Router
1967where
1968    R: RecorderRpc + Clone + Send + Sync + 'static,
1969{
1970    node_router_with_limits(
1971        runtime,
1972        recorder,
1973        DEFAULT_CLIENT_CONCURRENCY,
1974        DEFAULT_PEER_CONCURRENCY,
1975    )
1976}
1977
1978pub fn node_router_with_limits<R>(
1979    runtime: Arc<NodeRuntime>,
1980    recorder: R,
1981    client_concurrency: usize,
1982    peer_concurrency: usize,
1983) -> Router
1984where
1985    R: RecorderRpc + Clone + Send + Sync + 'static,
1986{
1987    node_router_with_optional_checkpoint(
1988        runtime,
1989        recorder,
1990        None,
1991        client_concurrency,
1992        peer_concurrency,
1993    )
1994}
1995
1996pub fn node_router_with_checkpoint<R>(
1997    runtime: Arc<NodeRuntime>,
1998    recorder: R,
1999    coordinator: Arc<CheckpointCoordinator>,
2000) -> Router
2001where
2002    R: RecorderRpc + Clone + Send + Sync + 'static,
2003{
2004    node_router_with_checkpoint_and_limits(
2005        runtime,
2006        recorder,
2007        coordinator,
2008        DEFAULT_CLIENT_CONCURRENCY,
2009        DEFAULT_PEER_CONCURRENCY,
2010    )
2011}
2012
2013pub fn node_router_with_checkpoint_and_limits<R>(
2014    runtime: Arc<NodeRuntime>,
2015    recorder: R,
2016    coordinator: Arc<CheckpointCoordinator>,
2017    client_concurrency: usize,
2018    peer_concurrency: usize,
2019) -> Router
2020where
2021    R: RecorderRpc + Clone + Send + Sync + 'static,
2022{
2023    node_router_with_optional_checkpoint(
2024        runtime,
2025        recorder,
2026        Some(coordinator),
2027        client_concurrency,
2028        peer_concurrency,
2029    )
2030}
2031
2032fn node_router_with_optional_checkpoint<R>(
2033    runtime: Arc<NodeRuntime>,
2034    recorder: R,
2035    coordinator: Option<Arc<CheckpointCoordinator>>,
2036    client_concurrency: usize,
2037    peer_concurrency: usize,
2038) -> Router
2039where
2040    R: RecorderRpc + Clone + Send + Sync + 'static,
2041{
2042    let peers = runtime.config.peers.clone();
2043    let recovery_generation = runtime.config.recovery_generation();
2044    let client_slots = Arc::new(tokio::sync::Semaphore::new(client_concurrency));
2045    let recorder_slots = Arc::new(tokio::sync::Semaphore::new(peer_concurrency));
2046    let log_slots = Arc::new(tokio::sync::Semaphore::new(peer_concurrency));
2047    let write_operations = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
2048    let (writer, writer_receiver) = tokio::sync::mpsc::channel(client_concurrency.max(1));
2049    tokio::spawn(writer_loop(
2050        Arc::downgrade(&runtime),
2051        coordinator.clone(),
2052        write_operations.clone(),
2053        writer_receiver,
2054        runtime.config.writer_batch_max,
2055        runtime.config.writer_batch_window,
2056    ));
2057    #[cfg(any(feature = "sql", feature = "graph", feature = "kv"))]
2058    let client_routes: Router = match runtime.config().execution_profile() {
2059        ExecutionProfile::Sqlite => {
2060            #[cfg(feature = "sql")]
2061            {
2062                Router::new()
2063                    .route(WRITE_PATH, post(handle_write))
2064                    .route(READ_PATH, post(handle_read))
2065                    .route(SQL_EXECUTE_PATH, post(handle_sql_execute))
2066                    .route(SQL_QUERY_PATH, post(handle_sql_query))
2067            }
2068            #[cfg(not(feature = "sql"))]
2069            unreachable!("SQL runtime cannot open without the sql feature")
2070        }
2071        ExecutionProfile::Graph => {
2072            #[cfg(feature = "graph")]
2073            {
2074                Router::new()
2075                    .route(GRAPH_PUT_DOCUMENT_PATH, post(handle_graph_put_document))
2076                    .route(
2077                        GRAPH_DELETE_DOCUMENT_PATH,
2078                        post(handle_graph_delete_document),
2079                    )
2080                    .route(GRAPH_GET_DOCUMENT_PATH, post(handle_graph_get_document))
2081                    .route(GRAPH_QUERY_PATH, post(handle_graph_query))
2082            }
2083            #[cfg(not(feature = "graph"))]
2084            unreachable!("graph runtime cannot open without the graph feature")
2085        }
2086        ExecutionProfile::Kv => {
2087            #[cfg(feature = "kv")]
2088            {
2089                Router::new()
2090                    .route(KV_PUT_PATH, post(handle_kv_put))
2091                    .route(KV_DELETE_PATH, post(handle_kv_delete))
2092                    .route(KV_GET_PATH, post(handle_kv_get))
2093                    .route(KV_SCAN_PATH, post(handle_kv_scan))
2094            }
2095            #[cfg(not(feature = "kv"))]
2096            unreachable!("KV runtime cannot open without the kv feature")
2097        }
2098    }
2099    .route_layer(middleware::from_fn_with_state(
2100        ClientGateState {
2101            runtime: runtime.clone(),
2102            slots: client_slots,
2103            coordinator: coordinator.clone(),
2104        },
2105        client_gate,
2106    ))
2107    .with_state(NodeRouteState {
2108        runtime: runtime.clone(),
2109        coordinator: coordinator.clone(),
2110        write_operations: write_operations.clone(),
2111        writer: writer.clone(),
2112    });
2113    #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
2114    let client_routes: Router = Router::new();
2115    let health_routes = Router::new()
2116        .route(LIVEZ_PATH, get(handle_livez))
2117        .route(READYZ_PATH, get(handle_readyz))
2118        .with_state(NodeRouteState {
2119            runtime: runtime.clone(),
2120            coordinator,
2121            write_operations,
2122            writer,
2123        });
2124    recorder_routes(recorder, peers.clone(), recovery_generation, recorder_slots)
2125        .merge(log_routes(
2126            RuntimeLogPeer { runtime },
2127            peers,
2128            recovery_generation,
2129            log_slots,
2130        ))
2131        .merge(client_routes)
2132        .merge(health_routes)
2133        .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
2134}
2135
2136fn recorder_routes<R>(
2137    recorder: R,
2138    peers: Vec<PeerConfig>,
2139    recovery_generation: u64,
2140    slots: Arc<tokio::sync::Semaphore>,
2141) -> Router
2142where
2143    R: RecorderRpc + Clone + Send + Sync + 'static,
2144{
2145    let recorder_peers = peers.clone();
2146    Router::new()
2147        .route(RECORDER_IDENTITY_PATH, post(handle_recorder_identity::<R>))
2148        .route(
2149            RECORDER_STORE_COMMAND_PATH,
2150            post(handle_recorder_store_command::<R>),
2151        )
2152        .route(
2153            RECORDER_FETCH_COMMAND_PATH,
2154            post(handle_recorder_fetch_command::<R>),
2155        )
2156        .route(
2157            RECORDER_INSPECT_PROOF_PATH,
2158            post(handle_recorder_inspect_proof::<R>),
2159        )
2160        .route(
2161            RECORDER_INSPECT_RECORD_PATH,
2162            post(handle_recorder_inspect_record::<R>),
2163        )
2164        .route(
2165            RECORDER_READ_FENCE_PATH,
2166            post(handle_recorder_read_fence::<R>),
2167        )
2168        .route(RECORDER_RECORD_PATH, post(handle_recorder_record::<R>))
2169        .route(
2170            RECORDER_INSTALL_PROOF_PATH,
2171            post(handle_recorder_install_proof::<R>),
2172        )
2173        .route_layer(middleware::from_fn_with_state(
2174            PeerGateState {
2175                peers,
2176                recovery_generation,
2177                protocol_version: RECORDER_PROTOCOL_VERSION,
2178                slots,
2179            },
2180            peer_gate,
2181        ))
2182        .with_state(RecorderRouteState {
2183            recorder,
2184            peers: recorder_peers,
2185        })
2186}
2187
2188fn log_routes<P>(
2189    peer: P,
2190    peers: Vec<PeerConfig>,
2191    recovery_generation: u64,
2192    slots: Arc<tokio::sync::Semaphore>,
2193) -> Router
2194where
2195    P: LogPeer + Clone + Send + Sync + 'static,
2196{
2197    Router::new()
2198        .route(LOG_FETCH_PATH, post(handle_fetch_log::<P>))
2199        .route_layer(middleware::from_fn_with_state(
2200            PeerGateState {
2201                peers,
2202                recovery_generation,
2203                protocol_version: PROTOCOL_VERSION,
2204                slots,
2205            },
2206            peer_gate,
2207        ))
2208        .with_state(LogRouteState { peer })
2209}
2210
2211async fn handle_recorder_identity<R>(
2212    State(state): State<RecorderRouteState<R>>,
2213    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2214    Json(request): Json<RecorderWire<()>>,
2215) -> Response
2216where
2217    R: RecorderRpc + Clone + Send + Sync + 'static,
2218{
2219    if request.version != RECORDER_WIRE_VERSION {
2220        return StatusCode::BAD_REQUEST.into_response();
2221    }
2222    let recorder = state.recorder;
2223    recorder_v2_response(
2224        tokio::task::spawn_blocking(move || {
2225            let _permit = permit;
2226            recorder.recorder_id()
2227        })
2228        .await,
2229    )
2230}
2231
2232async fn handle_recorder_store_command<R>(
2233    State(state): State<RecorderRouteState<R>>,
2234    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2235    Json(request): Json<RecorderWire<StoreCommandV2>>,
2236) -> Response
2237where
2238    R: RecorderRpc + Clone + Send + Sync + 'static,
2239{
2240    if request.version != RECORDER_WIRE_VERSION || !valid_recorder_command(&request.body.command) {
2241        return StatusCode::BAD_REQUEST.into_response();
2242    }
2243    let recorder = state.recorder;
2244    recorder_v2_response(
2245        tokio::task::spawn_blocking(move || {
2246            let _permit = permit;
2247            let body = request.body;
2248            recorder.store_command_for(
2249                body.cluster_id,
2250                body.epoch,
2251                body.config_id,
2252                body.config_digest,
2253                body.command_hash,
2254                body.command,
2255            )
2256        })
2257        .await,
2258    )
2259}
2260
2261async fn handle_recorder_fetch_command<R>(
2262    State(state): State<RecorderRouteState<R>>,
2263    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2264    Json(request): Json<RecorderWire<FetchCommandV2>>,
2265) -> Response
2266where
2267    R: RecorderRpc + Clone + Send + Sync + 'static,
2268{
2269    if request.version != RECORDER_WIRE_VERSION {
2270        return StatusCode::BAD_REQUEST.into_response();
2271    }
2272    let recorder = state.recorder;
2273    recorder_v2_response(
2274        tokio::task::spawn_blocking(move || {
2275            let _permit = permit;
2276            let body = request.body;
2277            recorder.fetch_command_for(
2278                body.cluster_id,
2279                body.epoch,
2280                body.config_id,
2281                body.config_digest,
2282                body.command_hash,
2283            )
2284        })
2285        .await,
2286    )
2287}
2288
2289async fn handle_recorder_inspect_proof<R>(
2290    State(state): State<RecorderRouteState<R>>,
2291    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2292    Json(request): Json<RecorderWire<InspectProofV2>>,
2293) -> Response
2294where
2295    R: RecorderRpc + Clone + Send + Sync + 'static,
2296{
2297    if request.version != RECORDER_WIRE_VERSION {
2298        return StatusCode::BAD_REQUEST.into_response();
2299    }
2300    let recorder = state.recorder;
2301    recorder_v2_response(
2302        tokio::task::spawn_blocking(move || {
2303            let _permit = permit;
2304            recorder.inspect_decision_proof(request.body.slot)
2305        })
2306        .await,
2307    )
2308}
2309
2310async fn handle_recorder_inspect_record<R>(
2311    State(state): State<RecorderRouteState<R>>,
2312    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2313    Json(request): Json<RecorderWire<InspectProofV2>>,
2314) -> Response
2315where
2316    R: RecorderRpc + Clone + Send + Sync + 'static,
2317{
2318    if request.version != RECORDER_WIRE_VERSION {
2319        return StatusCode::BAD_REQUEST.into_response();
2320    }
2321    let recorder = state.recorder;
2322    recorder_v2_response(
2323        tokio::task::spawn_blocking(move || {
2324            let _permit = permit;
2325            recorder.inspect_record_summary(request.body.slot)
2326        })
2327        .await,
2328    )
2329}
2330
2331async fn handle_recorder_read_fence<R>(
2332    State(state): State<RecorderRouteState<R>>,
2333    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2334    Json(request): Json<RecorderWire<ReadFenceRequest>>,
2335) -> Response
2336where
2337    R: RecorderRpc + Clone + Send + Sync + 'static,
2338{
2339    if request.version != RECORDER_WIRE_VERSION {
2340        return StatusCode::BAD_REQUEST.into_response();
2341    }
2342    let recorder = state.recorder;
2343    recorder_v2_response(
2344        tokio::task::spawn_blocking(move || {
2345            let _permit = permit;
2346            recorder.observe_read_fence(request.body)
2347        })
2348        .await,
2349    )
2350}
2351
2352async fn handle_recorder_record<R>(
2353    State(state): State<RecorderRouteState<R>>,
2354    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2355    Extension(authenticated_peer): Extension<AuthenticatedPeer>,
2356    Json(request): Json<RecorderWire<RecordRequest>>,
2357) -> Response
2358where
2359    R: RecorderRpc + Clone + Send + Sync + 'static,
2360{
2361    if request.version != RECORDER_WIRE_VERSION || !valid_recorder_record(&request.body) {
2362        return StatusCode::BAD_REQUEST.into_response();
2363    }
2364    if !authenticated_proposer_admitted(
2365        &authenticated_peer.0,
2366        &request.body.proposal.proposer_id,
2367        &state.peers,
2368    ) {
2369        return recorder_v2_response::<RecordSummary>(Ok(Err(rhiza_quepaxa::Error::Rejected(
2370            RejectReason::InvalidRequest,
2371        ))));
2372    }
2373    let recorder = state.recorder;
2374    recorder_v2_response(
2375        tokio::task::spawn_blocking(move || {
2376            let _permit = permit;
2377            recorder.record(request.body)
2378        })
2379        .await,
2380    )
2381}
2382
2383fn valid_recorder_command(command: &StoredCommand) -> bool {
2384    command.payload.len() <= MAX_COMMAND_BYTES
2385}
2386
2387fn valid_recorder_record(request: &RecordRequest) -> bool {
2388    !request.cluster_id.is_empty()
2389        && request.cluster_id.len() <= MAX_REQUEST_ID_BYTES
2390        && request.command.as_ref().is_none_or(valid_recorder_command)
2391}
2392
2393fn authenticated_proposer_admitted(
2394    authenticated_peer_id: &str,
2395    proposer_id: &str,
2396    peers: &[PeerConfig],
2397) -> bool {
2398    // Record requests carry config identity but not its membership. Configured peers are therefore
2399    // the transport identity authority for records and proofs until rebuilt after a transition.
2400    peers
2401        .iter()
2402        .any(|peer| peer.node_id == authenticated_peer_id)
2403        && peers.iter().any(|peer| peer.node_id == proposer_id)
2404}
2405
2406async fn handle_recorder_install_proof<R>(
2407    State(state): State<RecorderRouteState<R>>,
2408    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2409    Extension(authenticated_peer): Extension<AuthenticatedPeer>,
2410    Json(request): Json<RecorderWire<InstallProofV2>>,
2411) -> Response
2412where
2413    R: RecorderRpc + Clone + Send + Sync + 'static,
2414{
2415    if request.version != RECORDER_WIRE_VERSION {
2416        return StatusCode::BAD_REQUEST.into_response();
2417    }
2418    if !authenticated_proposer_admitted(
2419        &authenticated_peer.0,
2420        &request.body.proof.proposal().proposer_id,
2421        &state.peers,
2422    ) {
2423        return recorder_v2_response::<()>(Ok(Err(rhiza_quepaxa::Error::Rejected(
2424            RejectReason::InvalidRequest,
2425        ))));
2426    }
2427    let recorder = state.recorder;
2428    recorder_v2_response(
2429        tokio::task::spawn_blocking(move || {
2430            let _permit = permit;
2431            let membership = Membership::from_voters(request.body.members)?;
2432            recorder.install_decision_proof(request.body.proof, &membership)
2433        })
2434        .await,
2435    )
2436}
2437
2438fn recorder_v2_response<T: serde::Serialize>(
2439    result: Result<rhiza_quepaxa::Result<T>, tokio::task::JoinError>,
2440) -> Response {
2441    let (status, body) = match result {
2442        Ok(Ok(value)) => (StatusCode::OK, RecorderV2Result::Ok(value)),
2443        Ok(Err(rhiza_quepaxa::Error::Rejected(reason))) => {
2444            (StatusCode::CONFLICT, RecorderV2Result::Rejected(reason))
2445        }
2446        Ok(Err(error)) => (
2447            recorder_error_status(&error),
2448            RecorderV2Result::Error(error.to_string()),
2449        ),
2450        Err(error) => (
2451            StatusCode::INTERNAL_SERVER_ERROR,
2452            RecorderV2Result::Error(error.to_string()),
2453        ),
2454    };
2455    (
2456        status,
2457        Json(RecorderWire {
2458            version: RECORDER_WIRE_VERSION,
2459            body,
2460        }),
2461    )
2462        .into_response()
2463}
2464
2465async fn handle_fetch_log<P>(
2466    State(state): State<LogRouteState<P>>,
2467    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2468    Json(request): Json<FetchLogRequest>,
2469) -> Response
2470where
2471    P: LogPeer + Clone + Send + Sync + 'static,
2472{
2473    if request.from_index == 0 || request.max_entries > MAX_FETCH_ENTRIES {
2474        return StatusCode::BAD_REQUEST.into_response();
2475    }
2476    let peer = state.peer;
2477    let result = tokio::task::spawn_blocking(move || {
2478        let _permit = permit;
2479        peer.fetch_log(request)
2480    })
2481    .await;
2482    match result {
2483        Ok(Ok(response)) => Json(FetchLogHttpResponse::Fetched(response)).into_response(),
2484        Ok(Err(error)) => (
2485            fetch_log_error_status(&error),
2486            Json(FetchLogHttpResponse::Failed(error)),
2487        )
2488            .into_response(),
2489        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
2490    }
2491}
2492
2493#[cfg(feature = "sql")]
2494async fn handle_write(
2495    State(state): State<NodeRouteState>,
2496    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2497    request: Result<Json<WriteRequest>, JsonRejection>,
2498) -> Response {
2499    let request = match client_json(request) {
2500        Ok(request) => request,
2501        Err(response) => return response,
2502    };
2503    let payload = match canonical_put(&request.request_id, &request.key, &request.value) {
2504        Ok(payload) => payload,
2505        Err(error) => return node_error_response(error),
2506    };
2507    let request_id = request.request_id.clone();
2508    let operation = QueuedOperation::KeyValue {
2509        key: request.key,
2510        value: request.value,
2511    };
2512    coordinate_write(state, permit, request_id, payload, operation).await
2513}
2514
2515#[cfg(feature = "sql")]
2516async fn handle_sql_execute(
2517    State(state): State<NodeRouteState>,
2518    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2519    request: Result<Json<SqlExecuteRequest>, JsonRejection>,
2520) -> Response {
2521    let request = match client_json(request) {
2522        Ok(request) => request,
2523        Err(response) => return response,
2524    };
2525    if let Err(error) = validate_field(
2526        "request_id",
2527        &request.request_id,
2528        MAX_REQUEST_ID_BYTES,
2529        false,
2530    ) {
2531        return node_error_response(error);
2532    }
2533    let command = SqlCommand {
2534        request_id: request.request_id.clone(),
2535        statements: request.statements,
2536    };
2537    let payload = match encode_sql_command_with_index(&command) {
2538        Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
2539        Ok(_) => {
2540            return node_error_response(NodeError::InvalidRequest(format!(
2541                "command exceeds {MAX_COMMAND_BYTES} bytes"
2542            )))
2543        }
2544        Err(error) => return node_error_response(error),
2545    };
2546    let request_id = command.request_id.clone();
2547    coordinate_write(
2548        state,
2549        permit,
2550        request_id,
2551        payload,
2552        QueuedOperation::Sql(command),
2553    )
2554    .await
2555}
2556
2557async fn coordinate_write(
2558    state: NodeRouteState,
2559    permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2560    request_id: String,
2561    payload: Vec<u8>,
2562    operation: QueuedOperation,
2563) -> Response {
2564    let deadline = tokio::time::Instant::now() + CLIENT_WRITE_WAIT_TIMEOUT;
2565    let (mut receiver, queued) = {
2566        let mut operations = state.write_operations.lock().await;
2567        if let Some(operation) = operations.get(&request_id) {
2568            if operation.payload != payload {
2569                return client_error_response(
2570                    StatusCode::CONFLICT,
2571                    "request_conflict",
2572                    false,
2573                    "request id is already in flight with a different payload",
2574                    None,
2575                );
2576            }
2577            (operation.result.clone(), None)
2578        } else {
2579            let (sender, receiver) = tokio::sync::watch::channel(None);
2580            operations.insert(
2581                request_id.clone(),
2582                WriteOperation {
2583                    payload: payload.clone(),
2584                    result: receiver.clone(),
2585                },
2586            );
2587            (
2588                receiver,
2589                Some(QueuedWrite {
2590                    request_id: request_id.clone(),
2591                    payload,
2592                    operation,
2593                    permit,
2594                    sender,
2595                }),
2596            )
2597        }
2598    };
2599    if let Some(queued) = queued {
2600        match tokio::time::timeout_at(deadline, state.writer.send(queued)).await {
2601            Ok(Ok(())) => {}
2602            Ok(Err(_)) => {
2603                state.write_operations.lock().await.remove(&request_id);
2604                return client_error_response(
2605                    StatusCode::SERVICE_UNAVAILABLE,
2606                    "durability_unavailable",
2607                    true,
2608                    "writer queue is unavailable",
2609                    None,
2610                );
2611            }
2612            Err(_) => {
2613                state.write_operations.lock().await.remove(&request_id);
2614                return client_error_response(
2615                    StatusCode::SERVICE_UNAVAILABLE,
2616                    "write_timeout",
2617                    true,
2618                    "write did not enter the queue before the response deadline",
2619                    None,
2620                );
2621            }
2622        }
2623    }
2624    let wait = async {
2625        loop {
2626            if let Some(result) = receiver.borrow().clone() {
2627                return result;
2628            }
2629            if receiver.changed().await.is_err() {
2630                return WriteOperationResult::DurabilityUnavailable;
2631            }
2632        }
2633    };
2634    match tokio::time::timeout_at(deadline, wait).await {
2635        Ok(WriteOperationResult::Runtime(Ok(response))) => match response {
2636            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
2637            ClientWriteResponse::Unavailable => {
2638                unreachable!("no execution profiles are compiled in")
2639            }
2640            #[cfg(feature = "sql")]
2641            ClientWriteResponse::KeyValue(response) => Json(response).into_response(),
2642            #[cfg(feature = "sql")]
2643            ClientWriteResponse::Sql(response) => Json(response).into_response(),
2644            #[cfg(feature = "graph")]
2645            ClientWriteResponse::Graph(outcome) => {
2646                Json(graph_mutation_response(outcome)).into_response()
2647            }
2648            #[cfg(feature = "kv")]
2649            ClientWriteResponse::Kv(outcome) => Json(kv_mutation_response(outcome)).into_response(),
2650        },
2651        Ok(WriteOperationResult::Runtime(Err(error))) => node_error_response(error),
2652        Ok(WriteOperationResult::DurabilityUnavailable) => client_error_response(
2653            StatusCode::SERVICE_UNAVAILABLE,
2654            "durability_unavailable",
2655            true,
2656            "durability confirmation is unavailable",
2657            None,
2658        ),
2659        Err(_) => client_error_response(
2660            StatusCode::SERVICE_UNAVAILABLE,
2661            "write_timeout",
2662            true,
2663            "write did not complete before the response deadline",
2664            None,
2665        ),
2666    }
2667}
2668
2669async fn writer_loop(
2670    runtime: std::sync::Weak<NodeRuntime>,
2671    coordinator: Option<Arc<CheckpointCoordinator>>,
2672    write_operations: Arc<tokio::sync::Mutex<HashMap<String, WriteOperation>>>,
2673    mut receiver: tokio::sync::mpsc::Receiver<QueuedWrite>,
2674    batch_max: usize,
2675    batch_window: Duration,
2676) {
2677    while let Some(first) = receiver.recv().await {
2678        let mut queued = Vec::with_capacity(batch_max);
2679        queued.push(first);
2680        let deadline = tokio::time::Instant::now() + batch_window;
2681        while queued.len() < batch_max {
2682            match tokio::time::timeout_at(deadline, receiver.recv()).await {
2683                Ok(Some(next)) => queued.push(next),
2684                Ok(None) | Err(_) => break,
2685            }
2686        }
2687
2688        let mut dispatch = Vec::with_capacity(queued.len());
2689        let mut members = Vec::with_capacity(queued.len());
2690        for queued in queued {
2691            members.push(RuntimeBatchMember {
2692                #[cfg(feature = "sql")]
2693                request_id: queued.request_id.clone(),
2694                payload: queued.payload,
2695                operation: queued.operation,
2696            });
2697            dispatch.push((queued.request_id, queued.sender, queued.permit));
2698        }
2699
2700        let Some(runtime) = runtime.upgrade() else {
2701            for (request_id, sender, _permit) in dispatch {
2702                sender.send_replace(Some(WriteOperationResult::DurabilityUnavailable));
2703                write_operations.lock().await.remove(&request_id);
2704            }
2705            break;
2706        };
2707
2708        let blocking_runtime = runtime.clone();
2709        let results =
2710            tokio::task::spawn_blocking(move || blocking_runtime.execute_client_batch(members))
2711                .await
2712                .unwrap_or_else(|error| {
2713                    (0..dispatch.len())
2714                        .map(|_| {
2715                            Err(NodeError::Fatal(format!(
2716                                "writer batch task failed: {error}"
2717                            )))
2718                        })
2719                        .collect()
2720                });
2721        let dispatch = dispatch
2722            .into_iter()
2723            .map(|(request_id, sender, permit)| {
2724                drop(permit);
2725                (request_id, sender)
2726            })
2727            .collect::<Vec<_>>();
2728
2729        let committed_index = results
2730            .iter()
2731            .filter_map(|result| result.as_ref().ok().map(ClientWriteResponse::applied_index))
2732            .max();
2733        let durability_available = if let Some(index) = committed_index {
2734            confirm_write_durability(runtime.as_ref(), coordinator.as_deref(), index)
2735                .await
2736                .is_ok()
2737        } else {
2738            true
2739        };
2740
2741        for ((request_id, sender), result) in dispatch.into_iter().zip(results) {
2742            let delivered = if !durability_available && result.is_ok() {
2743                WriteOperationResult::DurabilityUnavailable
2744            } else {
2745                WriteOperationResult::Runtime(result)
2746            };
2747            sender.send_replace(Some(delivered));
2748            write_operations.lock().await.remove(&request_id);
2749        }
2750    }
2751}
2752
2753#[cfg(feature = "sql")]
2754async fn handle_sql_query(
2755    State(state): State<NodeRouteState>,
2756    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2757    request: Result<Json<SqlQueryRequest>, JsonRejection>,
2758) -> Response {
2759    let request = match client_json(request) {
2760        Ok(request) => request,
2761        Err(response) => return response,
2762    };
2763    let runtime = state.runtime;
2764    let consistency = request
2765        .consistency
2766        .unwrap_or(runtime.config.read_consistency());
2767    let max_rows = request.max_rows.unwrap_or(DEFAULT_SQL_MAX_ROWS);
2768    if max_rows == 0 || max_rows > MAX_SQL_MAX_ROWS {
2769        return node_error_response(NodeError::InvalidRequest(format!(
2770            "max_rows must be between 1 and {MAX_SQL_MAX_ROWS}"
2771        )));
2772    }
2773    let result = tokio::task::spawn_blocking(move || {
2774        let _permit = permit;
2775        runtime.query_sql(&request.statement, consistency, max_rows)
2776    })
2777    .await;
2778    match result {
2779        Ok(Ok(response)) => sql_query_http_response(response),
2780        Ok(Err(error)) => node_error_response(error),
2781        Err(error) => client_task_error(error),
2782    }
2783}
2784
2785#[cfg(feature = "sql")]
2786fn sql_query_http_response(response: SqlQueryResponse) -> Response {
2787    match serde_json::to_vec(&response) {
2788        Ok(encoded) if encoded.len() <= MAX_SQL_RESPONSE_BYTES => (
2789            [(axum::http::header::CONTENT_TYPE, "application/json")],
2790            encoded,
2791        )
2792            .into_response(),
2793        Ok(_) => node_error_response(NodeError::InvalidRequest(format!(
2794            "SQL response exceeds {MAX_SQL_RESPONSE_BYTES} bytes"
2795        ))),
2796        Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
2797    }
2798}
2799
2800#[cfg(feature = "graph")]
2801async fn handle_graph_put_document(
2802    State(state): State<NodeRouteState>,
2803    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2804    request: Result<Json<GraphPutDocumentRequest>, JsonRejection>,
2805) -> Response {
2806    let request = match client_json(request) {
2807        Ok(request) => request,
2808        Err(response) => return response,
2809    };
2810    let value = match GraphValueV1::try_from(request.value) {
2811        Ok(value) => value,
2812        Err(error) => return node_error_response(error),
2813    };
2814    let command = match GraphCommandV1::put_document(request.request_id, request.id, value) {
2815        Ok(command) => command,
2816        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2817    };
2818    execute_graph_mutation(state, permit, command).await
2819}
2820
2821#[cfg(feature = "graph")]
2822async fn handle_graph_delete_document(
2823    State(state): State<NodeRouteState>,
2824    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2825    request: Result<Json<GraphDeleteDocumentRequest>, JsonRejection>,
2826) -> Response {
2827    let request = match client_json(request) {
2828        Ok(request) => request,
2829        Err(response) => return response,
2830    };
2831    let command = match GraphCommandV1::delete_document(request.request_id, request.id) {
2832        Ok(command) => command,
2833        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2834    };
2835    execute_graph_mutation(state, permit, command).await
2836}
2837
2838#[cfg(feature = "graph")]
2839async fn execute_graph_mutation(
2840    state: NodeRouteState,
2841    permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2842    command: GraphCommandV1,
2843) -> Response {
2844    let payload = match encode_replicated_graph_command(&command) {
2845        Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
2846        Ok(_) => {
2847            return node_error_response(NodeError::InvalidRequest(format!(
2848                "command exceeds {MAX_COMMAND_BYTES} bytes"
2849            )))
2850        }
2851        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2852    };
2853    coordinate_write(
2854        state,
2855        permit,
2856        command.request_id().to_owned(),
2857        payload,
2858        QueuedOperation::Graph(command),
2859    )
2860    .await
2861}
2862
2863#[cfg(feature = "graph")]
2864async fn handle_graph_get_document(
2865    State(state): State<NodeRouteState>,
2866    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2867    request: Result<Json<GraphGetDocumentRequest>, JsonRejection>,
2868) -> Response {
2869    let request = match client_json(request) {
2870        Ok(request) => request,
2871        Err(response) => return response,
2872    };
2873    let runtime = state.runtime;
2874    let consistency = request
2875        .consistency
2876        .unwrap_or(runtime.config.read_consistency());
2877    let result = tokio::task::spawn_blocking(move || {
2878        let _permit = permit;
2879        runtime.get_graph_document(&request.id, consistency)
2880    })
2881    .await;
2882    match result {
2883        Ok(Ok(response)) => Json(GraphGetDocumentResponse {
2884            value: response.value.map(GraphValueDto::from),
2885            applied_index: response.applied_index,
2886            hash: response.hash,
2887        })
2888        .into_response(),
2889        Ok(Err(error)) => node_error_response(error),
2890        Err(error) => client_task_error(error),
2891    }
2892}
2893
2894#[cfg(feature = "graph")]
2895fn with_graph_client_permit<T>(
2896    permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2897    response_work: impl FnOnce() -> T,
2898) -> T {
2899    let result = response_work();
2900    drop(permit);
2901    result
2902}
2903
2904#[cfg(feature = "graph")]
2905async fn handle_graph_query(
2906    State(state): State<NodeRouteState>,
2907    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2908    request: Result<Json<GraphQueryRequest>, JsonRejection>,
2909) -> Response {
2910    let request = match client_json(request) {
2911        Ok(request) => request,
2912        Err(response) => return response,
2913    };
2914    let parameters = match request
2915        .statement
2916        .parameters
2917        .into_iter()
2918        .map(|(name, value)| GraphParameterValue::try_from(value).map(|value| (name, value)))
2919        .collect::<Result<BTreeMap<_, _>, _>>()
2920    {
2921        Ok(parameters) => parameters,
2922        Err(error) => return node_error_response(error),
2923    };
2924    let runtime = state.runtime;
2925    let consistency = request
2926        .consistency
2927        .unwrap_or(runtime.config.read_consistency());
2928    let max_rows = request.max_rows.unwrap_or(DEFAULT_GRAPH_MAX_ROWS);
2929    if max_rows == 0 || max_rows > MAX_GRAPH_MAX_ROWS {
2930        return node_error_response(NodeError::InvalidRequest(format!(
2931            "max_rows must be between 1 and {MAX_GRAPH_MAX_ROWS}"
2932        )));
2933    }
2934    let result = tokio::task::spawn_blocking(move || {
2935        runtime.query_graph(
2936            &request.statement.cypher,
2937            &parameters,
2938            consistency,
2939            max_rows,
2940        )
2941    })
2942    .await;
2943    with_graph_client_permit(permit, || match result {
2944        Ok(Ok(result)) => {
2945            let response = GraphQueryResponse::from(result);
2946            match serde_json::to_vec(&response) {
2947                Ok(encoded) if encoded.len() <= MAX_GRAPH_RESPONSE_BYTES => {
2948                    Json(response).into_response()
2949                }
2950                Ok(_) => node_error_response(NodeError::InvalidRequest(format!(
2951                    "graph response exceeds {MAX_GRAPH_RESPONSE_BYTES} bytes"
2952                ))),
2953                Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
2954            }
2955        }
2956        Ok(Err(error)) => node_error_response(error),
2957        Err(error) => client_task_error(error),
2958    })
2959}
2960
2961#[cfg(feature = "kv")]
2962async fn handle_kv_put(
2963    State(state): State<NodeRouteState>,
2964    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2965    request: Result<Json<KvPutRequest>, JsonRejection>,
2966) -> Response {
2967    let request = match client_json(request) {
2968        Ok(request) => request,
2969        Err(response) => return response,
2970    };
2971    let key = match decode_base64("key", &request.key) {
2972        Ok(value) => value,
2973        Err(error) => return node_error_response(error),
2974    };
2975    let value = match decode_base64("value", &request.value) {
2976        Ok(value) => value,
2977        Err(error) => return node_error_response(error),
2978    };
2979    let command = match KvCommandV1::put(request.request_id, key, value) {
2980        Ok(command) => command,
2981        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2982    };
2983    execute_kv_mutation(state, permit, command).await
2984}
2985
2986#[cfg(feature = "kv")]
2987async fn handle_kv_delete(
2988    State(state): State<NodeRouteState>,
2989    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2990    request: Result<Json<KvDeleteRequest>, JsonRejection>,
2991) -> Response {
2992    let request = match client_json(request) {
2993        Ok(request) => request,
2994        Err(response) => return response,
2995    };
2996    let key = match decode_base64("key", &request.key) {
2997        Ok(value) => value,
2998        Err(error) => return node_error_response(error),
2999    };
3000    let command = match KvCommandV1::delete(request.request_id, key) {
3001        Ok(command) => command,
3002        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3003    };
3004    execute_kv_mutation(state, permit, command).await
3005}
3006
3007#[cfg(feature = "kv")]
3008async fn execute_kv_mutation(
3009    state: NodeRouteState,
3010    permit: Arc<tokio::sync::OwnedSemaphorePermit>,
3011    command: KvCommandV1,
3012) -> Response {
3013    let payload = match encode_replicated_kv_command(&command) {
3014        Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
3015        Ok(_) => {
3016            return node_error_response(NodeError::InvalidRequest(format!(
3017                "command exceeds {MAX_COMMAND_BYTES} bytes"
3018            )))
3019        }
3020        Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3021    };
3022    coordinate_write(
3023        state,
3024        permit,
3025        command.request_id().to_owned(),
3026        payload,
3027        QueuedOperation::Kv(command),
3028    )
3029    .await
3030}
3031
3032#[cfg(feature = "kv")]
3033async fn handle_kv_get(
3034    State(state): State<NodeRouteState>,
3035    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3036    request: Result<Json<KvGetRequest>, JsonRejection>,
3037) -> Response {
3038    let request = match client_json(request) {
3039        Ok(request) => request,
3040        Err(response) => return response,
3041    };
3042    let key = match decode_base64("key", &request.key) {
3043        Ok(value) => value,
3044        Err(error) => return node_error_response(error),
3045    };
3046    let runtime = state.runtime;
3047    let consistency = request
3048        .consistency
3049        .unwrap_or(runtime.config.read_consistency());
3050    let result = tokio::task::spawn_blocking(move || {
3051        let _permit = permit;
3052        runtime.get_kv(&key, consistency)
3053    })
3054    .await;
3055    match result {
3056        Ok(Ok(response)) => Json(KvGetResponse {
3057            value: response.value.as_deref().map(encode_base64),
3058            applied_index: response.applied_index,
3059            hash: response.hash,
3060        })
3061        .into_response(),
3062        Ok(Err(error)) => node_error_response(error),
3063        Err(error) => client_task_error(error),
3064    }
3065}
3066
3067#[cfg(feature = "kv")]
3068enum DecodedKvScan {
3069    Range {
3070        start: Vec<u8>,
3071        end: Option<Vec<u8>>,
3072    },
3073    Prefix(Vec<u8>),
3074}
3075
3076#[cfg(feature = "kv")]
3077async fn handle_kv_scan(
3078    State(state): State<NodeRouteState>,
3079    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3080    request: Result<Json<KvScanRequest>, JsonRejection>,
3081) -> Response {
3082    let request = match client_json(request) {
3083        Ok(request) => request,
3084        Err(response) => return response,
3085    };
3086    let limit = request
3087        .limit
3088        .unwrap_or(usize::try_from(DEFAULT_KV_SCAN_LIMIT).expect("u32 fits usize"));
3089    if limit == 0 || limit > MAX_KV_SCAN_ROWS {
3090        return node_error_response(NodeError::InvalidRequest(format!(
3091            "limit must be between 1 and {MAX_KV_SCAN_ROWS}"
3092        )));
3093    }
3094    let cursor = match request.cursor {
3095        Some(cursor) => match decode_base64("cursor", &cursor) {
3096            Ok(cursor) => Some(cursor),
3097            Err(error) => return node_error_response(error),
3098        },
3099        None => None,
3100    };
3101    let scan = match (request.prefix, request.start, request.end) {
3102        (Some(prefix), None, None) => match decode_base64("prefix", &prefix) {
3103            Ok(prefix) => DecodedKvScan::Prefix(prefix),
3104            Err(error) => return node_error_response(error),
3105        },
3106        (None, Some(start), end) => {
3107            let start = match decode_base64("start", &start) {
3108                Ok(start) => start,
3109                Err(error) => return node_error_response(error),
3110            };
3111            let end = match end {
3112                Some(end) => match decode_base64("end", &end) {
3113                    Ok(end) => Some(end),
3114                    Err(error) => return node_error_response(error),
3115                },
3116                None => None,
3117            };
3118            DecodedKvScan::Range { start, end }
3119        }
3120        _ => {
3121            return node_error_response(NodeError::InvalidRequest(
3122                "provide either prefix alone or start with optional end".into(),
3123            ))
3124        }
3125    };
3126    let runtime = state.runtime;
3127    let consistency = request
3128        .consistency
3129        .unwrap_or(runtime.config.read_consistency());
3130    let result = tokio::task::spawn_blocking(move || {
3131        let _permit = permit;
3132        match scan {
3133            DecodedKvScan::Range { start, end } => runtime.scan_kv_range(
3134                &start,
3135                end.as_deref(),
3136                limit,
3137                cursor.as_deref(),
3138                consistency,
3139            ),
3140            DecodedKvScan::Prefix(prefix) => {
3141                runtime.scan_kv_prefix(&prefix, limit, cursor.as_deref(), consistency)
3142            }
3143        }
3144    })
3145    .await;
3146    match result {
3147        Ok(Ok(result)) => {
3148            let response = KvScanResponse {
3149                entries: result
3150                    .rows()
3151                    .iter()
3152                    .map(|row| KvScanEntryDto {
3153                        key: encode_base64(row.key()),
3154                        value: encode_base64(row.value()),
3155                    })
3156                    .collect(),
3157                next_cursor: result.next_cursor().map(encode_base64),
3158                applied_index: result.tip().applied_index(),
3159                hash: result.tip().applied_hash(),
3160            };
3161            match serde_json::to_vec(&response) {
3162                Ok(encoded) if encoded.len() <= MAX_KV_SCAN_RESPONSE_BYTES => {
3163                    Json(response).into_response()
3164                }
3165                Ok(_) => node_error_response(NodeError::ResourceExhausted(format!(
3166                    "KV scan response exceeds {MAX_KV_SCAN_RESPONSE_BYTES} bytes"
3167                ))),
3168                Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
3169            }
3170        }
3171        Ok(Err(error)) => node_error_response(error),
3172        Err(error) => client_task_error(error),
3173    }
3174}
3175
3176#[cfg(feature = "sql")]
3177async fn handle_read(
3178    State(state): State<NodeRouteState>,
3179    Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3180    request: Result<Json<ReadRequest>, JsonRejection>,
3181) -> Response {
3182    let request = match client_json(request) {
3183        Ok(request) => request,
3184        Err(response) => return response,
3185    };
3186    let runtime = state.runtime;
3187    let consistency = request
3188        .consistency
3189        .unwrap_or(runtime.config.read_consistency());
3190    let result = tokio::task::spawn_blocking(move || {
3191        let _permit = permit;
3192        runtime.read(&request.key, consistency)
3193    })
3194    .await;
3195    match result {
3196        Ok(Ok(response)) => Json(response).into_response(),
3197        Ok(Err(error)) => node_error_response(error),
3198        Err(error) => client_task_error(error),
3199    }
3200}
3201
3202async fn peer_gate(
3203    State(state): State<PeerGateState>,
3204    mut request: Request,
3205    next: Next,
3206) -> Response {
3207    if !recovery_generation_matches(request.headers(), state.recovery_generation) {
3208        return StatusCode::UNAUTHORIZED.into_response();
3209    }
3210    let Some(authenticated_peer) =
3211        authenticated_peer(request.headers(), &state.peers, state.protocol_version)
3212    else {
3213        return StatusCode::UNAUTHORIZED.into_response();
3214    };
3215    let permit = match state.slots.try_acquire_owned() {
3216        Ok(permit) => Arc::new(permit),
3217        Err(_) => return StatusCode::TOO_MANY_REQUESTS.into_response(),
3218    };
3219    request.extensions_mut().insert(permit);
3220    request
3221        .extensions_mut()
3222        .insert(AuthenticatedPeer(authenticated_peer));
3223    next.run(request).await
3224}
3225
3226async fn client_gate(
3227    State(state): State<ClientGateState>,
3228    mut request: Request,
3229    next: Next,
3230) -> Response {
3231    if !client_authenticated(request.headers(), state.runtime.config.client_token()) {
3232        return client_error_response(
3233            StatusCode::UNAUTHORIZED,
3234            "unauthorized",
3235            false,
3236            "client authentication failed",
3237            None,
3238        );
3239    }
3240    if let Some(response) = runtime_readiness_response(state.runtime.as_ref()) {
3241        return response;
3242    }
3243    if client_write_path(request.uri().path())
3244        && state
3245            .coordinator
3246            .as_ref()
3247            .is_some_and(|coordinator| coordinator.write_allowed().is_err())
3248    {
3249        return client_error_response(
3250            StatusCode::SERVICE_UNAVAILABLE,
3251            "writes_unavailable",
3252            true,
3253            "writes are temporarily unavailable",
3254            None,
3255        );
3256    }
3257    let permit = match state.slots.try_acquire_owned() {
3258        Ok(permit) => Arc::new(permit),
3259        Err(_) => {
3260            return client_error_response(
3261                StatusCode::TOO_MANY_REQUESTS,
3262                "overloaded",
3263                true,
3264                "client request capacity is exhausted",
3265                None,
3266            )
3267        }
3268    };
3269    request.extensions_mut().insert(permit);
3270    next.run(request).await
3271}
3272
3273fn client_write_path(path: &str) -> bool {
3274    #[cfg(feature = "sql")]
3275    if matches!(path, WRITE_PATH | SQL_EXECUTE_PATH) {
3276        return true;
3277    }
3278    #[cfg(feature = "graph")]
3279    if matches!(path, GRAPH_PUT_DOCUMENT_PATH | GRAPH_DELETE_DOCUMENT_PATH) {
3280        return true;
3281    }
3282    #[cfg(feature = "kv")]
3283    if matches!(path, KV_PUT_PATH | KV_DELETE_PATH) {
3284        return true;
3285    }
3286    false
3287}
3288
3289async fn handle_livez() -> StatusCode {
3290    StatusCode::OK
3291}
3292
3293async fn handle_readyz(State(state): State<NodeRouteState>) -> StatusCode {
3294    if state.runtime.is_ready()
3295        && state
3296            .coordinator
3297            .as_ref()
3298            .is_none_or(|coordinator| coordinator.health() == DurabilityHealth::Available)
3299    {
3300        StatusCode::OK
3301    } else {
3302        StatusCode::SERVICE_UNAVAILABLE
3303    }
3304}
3305
3306fn next_sync_flush_retry(current: Duration) -> Duration {
3307    current.saturating_mul(2).min(SYNC_FLUSH_RETRY_MAX)
3308}
3309
3310/// Confirms that a committed write has reached the configured durability boundary.
3311///
3312/// Synchronous archive I/O failures are retried with bounded backoff until the
3313/// archive recovers or the runtime begins shutdown.
3314pub async fn confirm_write_durability(
3315    runtime: &NodeRuntime,
3316    coordinator: Option<&CheckpointCoordinator>,
3317    index: LogIndex,
3318) -> Result<(), DurabilityError> {
3319    let Some(coordinator) = coordinator else {
3320        return Ok(());
3321    };
3322    coordinator.note_committed(index);
3323    if !matches!(coordinator.mode(), DurabilityMode::Sync) {
3324        return Ok(());
3325    }
3326
3327    let mut retry_delay = SYNC_FLUSH_RETRY_INITIAL;
3328    loop {
3329        if runtime.operation_cancelled.load(Ordering::Acquire) {
3330            return Err(DurabilityError::Unavailable);
3331        }
3332        match coordinator.flush_runtime(runtime, index).await {
3333            Ok(tip) if tip.index() >= index => return Ok(()),
3334            Ok(tip) => {
3335                return Err(DurabilityError::LocalLogGap {
3336                    expected: index,
3337                    actual: Some(tip.index()),
3338                })
3339            }
3340            Err(DurabilityError::Archive(_) | DurabilityError::Io(_)) => {
3341                let cancelled = runtime.operation_cancelled_notify.notified();
3342                tokio::pin!(cancelled);
3343                cancelled.as_mut().enable();
3344                if runtime.operation_cancelled.load(Ordering::Acquire) {
3345                    return Err(DurabilityError::Unavailable);
3346                }
3347                tokio::select! {
3348                    () = tokio::time::sleep(retry_delay) => {}
3349                    () = &mut cancelled => return Err(DurabilityError::Unavailable),
3350                }
3351                retry_delay = next_sync_flush_retry(retry_delay);
3352            }
3353            Err(error) => return Err(error),
3354        }
3355    }
3356}
3357
3358fn authenticated_peer(
3359    headers: &HeaderMap,
3360    peers: &[PeerConfig],
3361    protocol_version: &str,
3362) -> Option<String> {
3363    if header_text(headers, VERSION_HEADER) != Some(protocol_version) {
3364        return None;
3365    }
3366    let node_id = header_text(headers, NODE_ID_HEADER)?;
3367    let token = bearer_token(headers)?;
3368    peer_credentials_authenticated(node_id, token, peers).then(|| node_id.to_owned())
3369}
3370
3371fn peer_credentials_authenticated(node_id: &str, token: &str, peers: &[PeerConfig]) -> bool {
3372    peers
3373        .iter()
3374        .find(|peer| peer.node_id == node_id)
3375        .is_some_and(|peer| secrets_equal(peer.token.as_bytes(), token.as_bytes()))
3376}
3377
3378fn recovery_generation_matches(headers: &HeaderMap, expected: u64) -> bool {
3379    let expected = expected.to_string();
3380    header_text(headers, RECOVERY_GENERATION_HEADER) == Some(expected.as_str())
3381}
3382
3383fn client_authenticated(headers: &HeaderMap, expected_token: &str) -> bool {
3384    !expected_token.is_empty()
3385        && version_matches(headers)
3386        && bearer_token(headers)
3387            .is_some_and(|token| secrets_equal(expected_token.as_bytes(), token.as_bytes()))
3388}
3389
3390fn version_matches(headers: &HeaderMap) -> bool {
3391    header_text(headers, VERSION_HEADER) == Some(PROTOCOL_VERSION)
3392}
3393
3394fn header_text<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
3395    headers.get(name)?.to_str().ok()
3396}
3397
3398fn bearer_token(headers: &HeaderMap) -> Option<&str> {
3399    header_text(headers, "authorization")?.strip_prefix("Bearer ")
3400}
3401
3402fn secrets_equal(left: &[u8], right: &[u8]) -> bool {
3403    if left.len() != right.len() {
3404        return false;
3405    }
3406    left.iter()
3407        .zip(right)
3408        .fold(0_u8, |difference, (left, right)| {
3409            difference | (left ^ right)
3410        })
3411        == 0
3412}
3413
3414fn node_error_response(error: NodeError) -> Response {
3415    let (status, statement_index) = match &error {
3416        NodeError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, None),
3417        #[cfg(feature = "sql")]
3418        NodeError::InvalidSqlStatement {
3419            statement_index, ..
3420        } => (StatusCode::BAD_REQUEST, Some(*statement_index)),
3421        #[cfg(feature = "sql")]
3422        NodeError::RequestConflict(_) => (StatusCode::CONFLICT, None),
3423        NodeError::PreconditionFailed(_) => (StatusCode::CONFLICT, None),
3424        NodeError::SnapshotRequired(_)
3425        | NodeError::Unavailable(_)
3426        | NodeError::ResourceExhausted(_)
3427        | NodeError::ConfigurationTransition { .. }
3428        | NodeError::Contention(_)
3429        | NodeError::WinnerLimitExceeded => (StatusCode::SERVICE_UNAVAILABLE, None),
3430        NodeError::DataRootLocked(_)
3431        | NodeError::UnsupportedAckMode(_)
3432        | NodeError::ExecutionProfileMismatch { .. }
3433        | NodeError::Storage(_)
3434        | NodeError::Reconciliation(_)
3435        | NodeError::Invariant(_)
3436        | NodeError::Fatal(_) => (StatusCode::INTERNAL_SERVER_ERROR, None),
3437    };
3438    let classification = error.classification();
3439    if !matches!(&error, NodeError::Fatal(_)) {
3440        eprintln!(
3441            "node request failed (code={}, retryable={}): {}",
3442            classification.code(),
3443            classification.retryable(),
3444            escaped_error_detail(&error)
3445        );
3446    }
3447    client_error_response(
3448        status,
3449        classification.code(),
3450        classification.retryable(),
3451        node_error_message(status),
3452        statement_index,
3453    )
3454}
3455
3456fn node_error_message(status: StatusCode) -> &'static str {
3457    match status {
3458        StatusCode::BAD_REQUEST => "request could not be processed",
3459        StatusCode::CONFLICT => "request conflicts with current state",
3460        StatusCode::SERVICE_UNAVAILABLE => "service is temporarily unavailable",
3461        _ => "internal server error",
3462    }
3463}
3464
3465const MAX_ESCAPED_ERROR_DETAIL_BYTES: usize = 4 * 1024;
3466const ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER: &str = "...[truncated]";
3467
3468fn escaped_error_detail(error: &dyn fmt::Display) -> String {
3469    let detail = error.to_string();
3470    let mut escaped = String::with_capacity(detail.len().min(MAX_ESCAPED_ERROR_DETAIL_BYTES));
3471    for character in detail.chars() {
3472        let character_start = escaped.len();
3473        for escaped_character in character.escape_default() {
3474            if escaped.len()
3475                + escaped_character.len_utf8()
3476                + ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER.len()
3477                > MAX_ESCAPED_ERROR_DETAIL_BYTES
3478            {
3479                escaped.truncate(character_start);
3480                escaped.push_str(ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER);
3481                return escaped;
3482            }
3483            escaped.push(escaped_character);
3484        }
3485    }
3486    escaped
3487}
3488
3489#[allow(clippy::result_large_err)]
3490fn client_json<T>(request: Result<Json<T>, JsonRejection>) -> Result<T, Response> {
3491    request.map(|Json(request)| request).map_err(|rejection| {
3492        let status = rejection.status();
3493        eprintln!("invalid JSON request: {}", escaped_error_detail(&rejection));
3494        client_json_error_response(status)
3495    })
3496}
3497
3498fn client_json_error_response(status: StatusCode) -> Response {
3499    let code = if status == StatusCode::PAYLOAD_TOO_LARGE {
3500        "payload_too_large"
3501    } else {
3502        "invalid_json"
3503    };
3504    client_error_response(status, code, false, "request body is invalid", None)
3505}
3506
3507fn client_task_error(error: tokio::task::JoinError) -> Response {
3508    eprintln!("request task failed: {}", escaped_error_detail(&error));
3509    client_error_response(
3510        StatusCode::INTERNAL_SERVER_ERROR,
3511        "task_failed",
3512        false,
3513        "request task failed",
3514        None,
3515    )
3516}
3517
3518fn client_error_response(
3519    status: StatusCode,
3520    code: impl Into<String>,
3521    retryable: bool,
3522    message: impl Into<String>,
3523    statement_index: Option<usize>,
3524) -> Response {
3525    (
3526        status,
3527        Json(ClientErrorResponse {
3528            code: code.into(),
3529            retryable,
3530            message: message.into(),
3531            statement_index,
3532        }),
3533    )
3534        .into_response()
3535}
3536
3537pub fn install_successor_recorder(
3538    recorder: &RecorderFileStore,
3539    next_config_id: u64,
3540    membership: Membership,
3541    stop: &StopInformation,
3542) -> Result<rhiza_quepaxa::ConfigurationState, NodeError> {
3543    if stop.version != 2 || stop.entry.config_id.checked_add(1) != Some(next_config_id) {
3544        return Err(NodeError::PreconditionFailed(
3545            "successor identity does not match the Stop proof".into(),
3546        ));
3547    }
3548    recorder
3549        .install_successor_from_proof(membership, &stop.proof)
3550        .map_err(|error| NodeError::Reconciliation(error.to_string()))
3551}
3552
3553pub fn recover_successor_recorder_after_checkpoint(
3554    recorder: &RecorderFileStore,
3555    config: &NodeConfig,
3556    next_config_id: u64,
3557    membership: Membership,
3558    stop: &StopInformation,
3559) -> Result<rhiza_quepaxa::ConfigurationState, NodeError> {
3560    let installed = install_successor_recorder(recorder, next_config_id, membership.clone(), stop)?;
3561    let log = FileLogStore::open_with_configuration(
3562        config.data_dir.join("consensus/log"),
3563        &config.cluster_id,
3564        config.epoch,
3565        config.log_initial_configuration.clone(),
3566    )
3567    .map_err(|error| NodeError::Storage(error.to_string()))?;
3568    let recovered_configuration = log
3569        .configuration_state()
3570        .map_err(|error| NodeError::Storage(error.to_string()))?;
3571    if !recovered_configuration.is_active() {
3572        return Ok(installed);
3573    }
3574    if recovered_configuration.config_id() != next_config_id
3575        || recovered_configuration.digest() != membership.digest()
3576    {
3577        return Err(NodeError::Reconciliation(
3578            "recovered successor qlog configuration does not match the target bundle".into(),
3579        ));
3580    }
3581    let tip = log
3582        .logical_state()
3583        .map_err(|error| NodeError::Storage(error.to_string()))?
3584        .tip
3585        .ok_or_else(|| NodeError::Reconciliation("recovered successor qlog is empty".into()))?;
3586    recorder
3587        .recover_successor_activation_from_checkpoint(
3588            stop.entry.index,
3589            stop.entry.hash,
3590            tip.index(),
3591            tip.hash(),
3592        )
3593        .map_err(|error| NodeError::Reconciliation(error.to_string()))
3594}
3595
3596fn recorder_error_status(error: &rhiza_quepaxa::Error) -> StatusCode {
3597    match error {
3598        rhiza_quepaxa::Error::NoQuorum
3599        | rhiza_quepaxa::Error::CommandUnavailable
3600        | rhiza_quepaxa::Error::Io(_)
3601        | rhiza_quepaxa::Error::RecorderRootLocked(_) => StatusCode::SERVICE_UNAVAILABLE,
3602        rhiza_quepaxa::Error::Rejected(_) => StatusCode::CONFLICT,
3603        _ => StatusCode::INTERNAL_SERVER_ERROR,
3604    }
3605}
3606
3607fn fetch_log_error_status(error: &FetchLogError) -> StatusCode {
3608    match error {
3609        FetchLogError::InvalidRequest { .. } => StatusCode::BAD_REQUEST,
3610        FetchLogError::SnapshotRequired { .. } | FetchLogError::Gap { .. } => StatusCode::CONFLICT,
3611        FetchLogError::Decode { .. } | FetchLogError::Transport { .. } => {
3612            StatusCode::SERVICE_UNAVAILABLE
3613        }
3614        FetchLogError::InvalidAnchor { .. }
3615        | FetchLogError::InvalidEntry { .. }
3616        | FetchLogError::ForeignIdentity { .. } => StatusCode::INTERNAL_SERVER_ERROR,
3617    }
3618}
3619
3620fn runtime_readiness_response(runtime: &NodeRuntime) -> Option<Response> {
3621    if runtime.is_fatal() {
3622        Some(client_error_response(
3623            StatusCode::INTERNAL_SERVER_ERROR,
3624            "fatal",
3625            false,
3626            "node is fatally unavailable",
3627            None,
3628        ))
3629    } else if !runtime.is_ready() {
3630        Some(client_error_response(
3631            StatusCode::SERVICE_UNAVAILABLE,
3632            "unavailable",
3633            true,
3634            "node is not ready",
3635            None,
3636        ))
3637    } else {
3638        None
3639    }
3640}
3641
3642#[derive(Debug, serde::Deserialize, serde::Serialize)]
3643#[serde(tag = "status", content = "body")]
3644enum FetchLogHttpResponse {
3645    Fetched(FetchLogResponse),
3646    Failed(FetchLogError),
3647}
3648
3649pub fn catch_up_missing_entries<P: LogPeer + ?Sized>(
3650    local_last_index: LogIndex,
3651    local_last_hash: LogHash,
3652    cluster_id: &str,
3653    epoch: u64,
3654    config_id: u64,
3655    peer: &P,
3656    max_entries: u32,
3657) -> Result<Vec<LogEntry>, FetchLogError> {
3658    if max_entries == 0 {
3659        return Ok(Vec::new());
3660    }
3661    if max_entries > MAX_FETCH_ENTRIES {
3662        return Err(FetchLogError::InvalidRequest {
3663            message: format!("max_entries exceeds {MAX_FETCH_ENTRIES}"),
3664        });
3665    }
3666    if cluster_id.is_empty() {
3667        return Err(FetchLogError::InvalidRequest {
3668            message: "cluster_id must not be empty".into(),
3669        });
3670    }
3671    let from_index =
3672        local_last_index
3673            .checked_add(1)
3674            .ok_or_else(|| FetchLogError::InvalidRequest {
3675                message: "local qlog index is exhausted".into(),
3676            })?;
3677    let response = peer.fetch_log(FetchLogRequest {
3678        from_index,
3679        max_entries,
3680    })?;
3681    if response.entries.len() > max_entries as usize {
3682        return Err(FetchLogError::InvalidRequest {
3683            message: "peer returned more entries than requested".into(),
3684        });
3685    }
3686    if response.last_index < local_last_index {
3687        return Err(FetchLogError::Gap {
3688            expected: local_last_index,
3689            actual: Some(response.last_index),
3690        });
3691    }
3692    if response.entries.is_empty() && response.last_index >= from_index {
3693        return Err(FetchLogError::Gap {
3694            expected: from_index,
3695            actual: None,
3696        });
3697    }
3698    validate_fetched_entries(
3699        from_index,
3700        local_last_hash,
3701        cluster_id,
3702        epoch,
3703        config_id,
3704        response.entries,
3705    )
3706}
3707
3708fn validate_fetched_entries(
3709    from_index: LogIndex,
3710    local_last_hash: LogHash,
3711    cluster_id: &str,
3712    epoch: u64,
3713    config_id: u64,
3714    entries: Vec<LogEntry>,
3715) -> Result<Vec<LogEntry>, FetchLogError> {
3716    validate_fetched_entries_with_configuration(
3717        from_index,
3718        local_last_hash,
3719        cluster_id,
3720        epoch,
3721        ConfigurationState::active(config_id, LogHash::ZERO),
3722        entries,
3723    )
3724}
3725
3726fn validate_fetched_entries_with_configuration(
3727    from_index: LogIndex,
3728    local_last_hash: LogHash,
3729    cluster_id: &str,
3730    epoch: u64,
3731    mut configuration_state: ConfigurationState,
3732    entries: Vec<LogEntry>,
3733) -> Result<Vec<LogEntry>, FetchLogError> {
3734    let mut expected = from_index;
3735    let mut previous_hash = local_last_hash;
3736    for entry in &entries {
3737        if entry.index != expected {
3738            return Err(FetchLogError::Gap {
3739                expected,
3740                actual: Some(entry.index),
3741            });
3742        }
3743        if entry.cluster_id != cluster_id || entry.epoch != epoch {
3744            return Err(FetchLogError::ForeignIdentity { index: entry.index });
3745        }
3746        if entry.prev_hash != previous_hash {
3747            return Err(FetchLogError::InvalidAnchor {
3748                expected: previous_hash,
3749                actual: entry.prev_hash,
3750            });
3751        }
3752        if entry.recompute_hash() != entry.hash {
3753            return Err(FetchLogError::InvalidEntry {
3754                index: entry.index,
3755                message: "hash does not match entry contents".into(),
3756            });
3757        }
3758        validate_entry_shape(entry).map_err(|message| FetchLogError::InvalidEntry {
3759            index: entry.index,
3760            message,
3761        })?;
3762        configuration_state = configuration_state.validate_entry(entry).map_err(|error| {
3763            FetchLogError::InvalidEntry {
3764                index: entry.index,
3765                message: error.to_string(),
3766            }
3767        })?;
3768        expected = expected
3769            .checked_add(1)
3770            .ok_or_else(|| FetchLogError::InvalidEntry {
3771                index: entry.index,
3772                message: "qlog index is exhausted".into(),
3773            })?;
3774        previous_hash = entry.hash;
3775    }
3776    Ok(entries)
3777}
3778
3779#[derive(Clone, Eq, PartialEq)]
3780pub struct PeerConfig {
3781    node_id: String,
3782    base_url: String,
3783    log_base_url: String,
3784    token: String,
3785}
3786
3787impl fmt::Debug for PeerConfig {
3788    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3789        f.debug_struct("PeerConfig")
3790            .field("node_id", &self.node_id)
3791            .field("base_url", &self.base_url)
3792            .field("log_base_url", &self.log_base_url)
3793            .field("token", &"[redacted]")
3794            .finish()
3795    }
3796}
3797
3798impl PeerConfig {
3799    pub fn new(
3800        node_id: impl Into<String>,
3801        base_url: impl Into<String>,
3802        token: impl Into<String>,
3803    ) -> Result<Self, ConfigError> {
3804        let base_url = base_url.into();
3805        Self::new_with_log_url(node_id, base_url.clone(), base_url, token)
3806    }
3807
3808    pub fn new_with_log_url(
3809        node_id: impl Into<String>,
3810        base_url: impl Into<String>,
3811        log_base_url: impl Into<String>,
3812        token: impl Into<String>,
3813    ) -> Result<Self, ConfigError> {
3814        let node_id = node_id.into();
3815        if !valid_nonblank_header_value(&node_id) {
3816            return Err(ConfigError::EmptyPeerNodeId);
3817        }
3818        let base_url = validate_peer_base_url(base_url.into())?;
3819        let log_base_url = validate_peer_base_url(log_base_url.into())?;
3820        let token = token.into();
3821        if !valid_auth_token(&token) {
3822            return Err(ConfigError::EmptyPeerToken);
3823        }
3824        Ok(Self {
3825            node_id,
3826            base_url,
3827            log_base_url,
3828            token,
3829        })
3830    }
3831
3832    pub fn node_id(&self) -> &str {
3833        &self.node_id
3834    }
3835
3836    pub fn base_url(&self) -> &str {
3837        &self.base_url
3838    }
3839
3840    pub fn log_base_url(&self) -> &str {
3841        &self.log_base_url
3842    }
3843
3844    pub fn token(&self) -> &str {
3845        &self.token
3846    }
3847}
3848
3849fn validate_peer_base_url(url: String) -> Result<String, ConfigError> {
3850    let url = url.trim_end_matches('/').to_string();
3851    if url.trim().is_empty() {
3852        return Err(ConfigError::EmptyPeerBaseUrl);
3853    }
3854    let parsed =
3855        reqwest::Url::parse(&url).map_err(|_| ConfigError::InvalidPeerBaseUrl(url.clone()))?;
3856    if !matches!(parsed.scheme(), "http" | "https")
3857        || parsed.host_str().is_none()
3858        || !parsed.username().is_empty()
3859        || parsed.password().is_some()
3860        || parsed.path() != "/"
3861        || parsed.query().is_some()
3862        || parsed.fragment().is_some()
3863    {
3864        return Err(ConfigError::InvalidPeerBaseUrl(url));
3865    }
3866    Ok(url)
3867}
3868
3869pub(crate) fn valid_nonblank_header_value(value: &str) -> bool {
3870    !value.trim().is_empty()
3871        && axum::http::HeaderValue::try_from(value).is_ok_and(|value| value.to_str().is_ok())
3872}
3873
3874pub(crate) fn valid_auth_token(value: &str) -> bool {
3875    valid_nonblank_header_value(value) && !value.chars().any(char::is_whitespace)
3876}
3877
3878#[derive(Clone, Eq, PartialEq)]
3879pub struct NodeConfig {
3880    cluster_id_source: String,
3881    logical_cluster_id: String,
3882    cluster_id: String,
3883    node_id: String,
3884    data_dir: PathBuf,
3885    epoch: u64,
3886    membership: Membership,
3887    log_initial_configuration: ConfigurationState,
3888    configuration_state: ConfigurationState,
3889    predecessor_stop_entry: Option<LogEntry>,
3890    recovery_generation: u64,
3891    peers: Vec<PeerConfig>,
3892    client_token: String,
3893    read_consistency: ReadConsistency,
3894    ack_mode: AckMode,
3895    writer_batch_max: usize,
3896    writer_batch_window: Duration,
3897    execution_profile: ExecutionProfile,
3898    #[cfg(feature = "sql")]
3899    sql_write_profiler: Option<SqlWriteProfiler>,
3900    #[cfg(feature = "sql")]
3901    sql_group_commit_queue_capacity: usize,
3902}
3903
3904impl fmt::Debug for NodeConfig {
3905    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3906        let mut debug = f.debug_struct("NodeConfig");
3907        debug
3908            .field("cluster_id_source", &self.cluster_id_source)
3909            .field("logical_cluster_id", &self.logical_cluster_id)
3910            .field("cluster_id", &self.cluster_id)
3911            .field("node_id", &self.node_id)
3912            .field("data_dir", &self.data_dir)
3913            .field("epoch", &self.epoch)
3914            .field("membership", &self.membership.members())
3915            .field("log_initial_configuration", &self.log_initial_configuration)
3916            .field("configuration_state", &self.configuration_state)
3917            .field(
3918                "predecessor_stop_entry",
3919                &self
3920                    .predecessor_stop_entry
3921                    .as_ref()
3922                    .map(|entry| (entry.index, entry.hash)),
3923            )
3924            .field("recovery_generation", &self.recovery_generation)
3925            .field("peers", &self.peers)
3926            .field("client_token", &"[redacted]")
3927            .field("read_consistency", &self.read_consistency)
3928            .field("ack_mode", &self.ack_mode)
3929            .field("writer_batch_max", &self.writer_batch_max)
3930            .field("writer_batch_window", &self.writer_batch_window)
3931            .field("execution_profile", &self.execution_profile);
3932        #[cfg(feature = "sql")]
3933        debug.field(
3934            "sql_write_profiler",
3935            &self.sql_write_profiler.as_ref().map(|_| "installed"),
3936        );
3937        #[cfg(feature = "sql")]
3938        debug.field(
3939            "sql_group_commit_queue_capacity",
3940            &self.sql_group_commit_queue_capacity,
3941        );
3942        debug.finish()
3943    }
3944}
3945
3946impl NodeConfig {
3947    pub fn new<P>(
3948        cluster_id: impl Into<String>,
3949        node_id: impl Into<String>,
3950        data_dir: PathBuf,
3951        epoch: u64,
3952        config_id: u64,
3953        peers: P,
3954        client_token: impl Into<String>,
3955    ) -> Result<Self, ConfigError>
3956    where
3957        P: Into<Vec<PeerConfig>>,
3958    {
3959        let peers = peers.into();
3960        let membership = membership_from_peers(&peers)?;
3961        let configuration_state = ConfigurationState::active(config_id, membership.digest());
3962        Self::new_with_configuration(
3963            cluster_id,
3964            node_id,
3965            data_dir,
3966            epoch,
3967            membership,
3968            configuration_state,
3969            peers,
3970            client_token,
3971        )
3972    }
3973
3974    pub fn new_embedded<I, S>(
3975        cluster_id: impl Into<String>,
3976        node_id: impl Into<String>,
3977        data_dir: PathBuf,
3978        epoch: u64,
3979        config_id: u64,
3980        members: I,
3981    ) -> Result<Self, ConfigError>
3982    where
3983        I: IntoIterator<Item = S>,
3984        S: Into<String>,
3985    {
3986        let cluster_id = cluster_id.into();
3987        let node_id = node_id.into();
3988        validate_node_identity(&cluster_id, &node_id, &data_dir, epoch, config_id)?;
3989        let membership = membership_from_node_ids(members.into_iter().map(Into::into).collect())?;
3990        if !membership.contains(&node_id) {
3991            return Err(ConfigError::LocalNodeMissing);
3992        }
3993        let configuration_state = ConfigurationState::active(config_id, membership.digest());
3994        Self::from_validated_parts(
3995            cluster_id,
3996            node_id,
3997            data_dir,
3998            epoch,
3999            membership,
4000            configuration_state,
4001            Vec::new(),
4002            String::new(),
4003        )
4004    }
4005
4006    #[allow(clippy::too_many_arguments)]
4007    pub fn new_with_configuration<P>(
4008        cluster_id: impl Into<String>,
4009        node_id: impl Into<String>,
4010        data_dir: PathBuf,
4011        epoch: u64,
4012        membership: Membership,
4013        configuration_state: ConfigurationState,
4014        peers: P,
4015        client_token: impl Into<String>,
4016    ) -> Result<Self, ConfigError>
4017    where
4018        P: Into<Vec<PeerConfig>>,
4019    {
4020        let cluster_id = cluster_id.into();
4021        let node_id = node_id.into();
4022        let client_token = client_token.into();
4023        let peers = peers.into();
4024
4025        validate_node_identity(
4026            &cluster_id,
4027            &node_id,
4028            &data_dir,
4029            epoch,
4030            configuration_state.config_id(),
4031        )?;
4032        if !(3..=7).contains(&peers.len()) {
4033            return Err(ConfigError::InvalidPeerCount(peers.len()));
4034        }
4035        let mut peer_ids = HashSet::with_capacity(peers.len());
4036        let mut peer_tokens = HashSet::with_capacity(peers.len());
4037        for peer in &peers {
4038            if !peer_ids.insert(peer.node_id.clone()) {
4039                return Err(ConfigError::DuplicatePeerNodeId(peer.node_id.clone()));
4040            }
4041            if !peer_tokens.insert(peer.token.as_str()) {
4042                return Err(ConfigError::DuplicatePeerToken);
4043            }
4044        }
4045        if !peer_ids.contains(&node_id) {
4046            return Err(ConfigError::LocalNodeMissing);
4047        }
4048        if peer_ids.len() != membership.members().len()
4049            || membership
4050                .members()
4051                .iter()
4052                .any(|member| !peer_ids.contains(member))
4053        {
4054            return Err(ConfigError::PeerMembershipMismatch);
4055        }
4056        if configuration_state.is_active() && configuration_state.digest() != membership.digest() {
4057            return Err(ConfigError::PeerMembershipMismatch);
4058        }
4059        if !valid_auth_token(&client_token) {
4060            return Err(ConfigError::EmptyClientToken);
4061        }
4062        if peer_tokens.contains(client_token.as_str()) {
4063            return Err(ConfigError::ClientTokenConflictsWithPeer);
4064        }
4065
4066        Self::from_validated_parts(
4067            cluster_id,
4068            node_id,
4069            data_dir,
4070            epoch,
4071            membership,
4072            configuration_state,
4073            peers,
4074            client_token,
4075        )
4076    }
4077
4078    #[allow(clippy::too_many_arguments)]
4079    fn from_validated_parts(
4080        cluster_id: String,
4081        node_id: String,
4082        data_dir: PathBuf,
4083        epoch: u64,
4084        membership: Membership,
4085        configuration_state: ConfigurationState,
4086        peers: Vec<PeerConfig>,
4087        client_token: String,
4088    ) -> Result<Self, ConfigError> {
4089        let log_initial_configuration = ConfigurationState::active(
4090            configuration_state.config_id(),
4091            configuration_state.digest(),
4092        );
4093        let execution_profile =
4094            canonical_cluster_profile(&cluster_id).unwrap_or(ExecutionProfile::Sqlite);
4095        let logical_cluster_id = ["rhiza:sql:", "rhiza:graph:", "rhiza:kv:"]
4096            .into_iter()
4097            .find_map(|prefix| cluster_id.strip_prefix(prefix))
4098            .unwrap_or(&cluster_id)
4099            .to_owned();
4100        let effective_cluster_id = effective_cluster_id(execution_profile, &cluster_id)?;
4101        Ok(Self {
4102            cluster_id_source: cluster_id,
4103            cluster_id: effective_cluster_id,
4104            logical_cluster_id,
4105            node_id,
4106            data_dir,
4107            epoch,
4108            membership,
4109            log_initial_configuration,
4110            configuration_state,
4111            predecessor_stop_entry: None,
4112            recovery_generation: 1,
4113            peers,
4114            client_token,
4115            read_consistency: ReadConsistency::ReadBarrier,
4116            ack_mode: AckMode::HaFirst,
4117            writer_batch_max: DEFAULT_WRITER_BATCH_MAX,
4118            writer_batch_window: DEFAULT_WRITER_BATCH_WINDOW,
4119            execution_profile,
4120            #[cfg(feature = "sql")]
4121            sql_write_profiler: None,
4122            #[cfg(feature = "sql")]
4123            sql_group_commit_queue_capacity: DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY,
4124        })
4125    }
4126
4127    pub fn with_execution_profile(
4128        mut self,
4129        execution_profile: ExecutionProfile,
4130    ) -> Result<Self, ConfigError> {
4131        self.cluster_id = effective_cluster_id(execution_profile, &self.cluster_id_source)?;
4132        self.execution_profile = execution_profile;
4133        Ok(self)
4134    }
4135
4136    pub fn with_read_consistency(mut self, read_consistency: ReadConsistency) -> Self {
4137        self.read_consistency = read_consistency;
4138        self
4139    }
4140
4141    #[cfg(feature = "sql")]
4142    pub fn with_sql_write_profiler(mut self, profiler: SqlWriteProfiler) -> Self {
4143        self.sql_write_profiler = Some(profiler);
4144        self
4145    }
4146
4147    #[cfg(feature = "sql")]
4148    pub fn with_sql_group_commit_queue_capacity(
4149        mut self,
4150        capacity: usize,
4151    ) -> Result<Self, ConfigError> {
4152        if !(1..=MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY).contains(&capacity) {
4153            return Err(ConfigError::InvalidSqlGroupCommitQueueCapacity(capacity));
4154        }
4155        self.sql_group_commit_queue_capacity = capacity;
4156        Ok(self)
4157    }
4158
4159    pub fn with_ack_mode(mut self, ack_mode: AckMode) -> Self {
4160        self.ack_mode = ack_mode;
4161        self
4162    }
4163
4164    pub fn with_writer_batching(
4165        mut self,
4166        max: usize,
4167        window: Duration,
4168    ) -> Result<Self, ConfigError> {
4169        if max == 0 || max > MAX_WRITE_BATCH_MEMBERS {
4170            return Err(ConfigError::InvalidWriterBatchMax(max));
4171        }
4172        if window.is_zero() || window >= CLIENT_WRITE_WAIT_TIMEOUT {
4173            return Err(ConfigError::InvalidWriterBatchWindow);
4174        }
4175        self.writer_batch_max = max;
4176        self.writer_batch_window = window;
4177        Ok(self)
4178    }
4179
4180    pub fn with_log_initial_configuration(mut self, configuration: ConfigurationState) -> Self {
4181        self.log_initial_configuration = configuration;
4182        self
4183    }
4184
4185    pub fn with_predecessor_stop_entry(mut self, entry: LogEntry) -> Self {
4186        self.predecessor_stop_entry = Some(entry);
4187        self
4188    }
4189
4190    pub fn with_recovery_generation(
4191        mut self,
4192        recovery_generation: u64,
4193    ) -> Result<Self, ConfigError> {
4194        validate_recovery_generation(recovery_generation)?;
4195        self.recovery_generation = recovery_generation;
4196        Ok(self)
4197    }
4198
4199    pub fn cluster_id(&self) -> &str {
4200        &self.cluster_id
4201    }
4202
4203    pub fn logical_cluster_id(&self) -> &str {
4204        &self.logical_cluster_id
4205    }
4206
4207    pub fn node_id(&self) -> &str {
4208        &self.node_id
4209    }
4210
4211    pub fn data_dir(&self) -> &PathBuf {
4212        &self.data_dir
4213    }
4214
4215    pub const fn epoch(&self) -> u64 {
4216        self.epoch
4217    }
4218
4219    pub const fn config_id(&self) -> u64 {
4220        self.configuration_state.config_id()
4221    }
4222
4223    pub const fn recovery_generation(&self) -> u64 {
4224        self.recovery_generation
4225    }
4226
4227    pub fn peers(&self) -> &[PeerConfig] {
4228        &self.peers
4229    }
4230
4231    pub const fn membership(&self) -> &Membership {
4232        &self.membership
4233    }
4234
4235    pub const fn configuration_state(&self) -> &ConfigurationState {
4236        &self.configuration_state
4237    }
4238
4239    pub const fn log_initial_configuration(&self) -> &ConfigurationState {
4240        &self.log_initial_configuration
4241    }
4242
4243    pub fn client_token(&self) -> &str {
4244        &self.client_token
4245    }
4246
4247    pub const fn read_consistency(&self) -> ReadConsistency {
4248        self.read_consistency
4249    }
4250
4251    pub const fn ack_mode(&self) -> AckMode {
4252        self.ack_mode
4253    }
4254
4255    pub const fn writer_batch_max(&self) -> usize {
4256        self.writer_batch_max
4257    }
4258
4259    pub const fn writer_batch_window(&self) -> Duration {
4260        self.writer_batch_window
4261    }
4262
4263    pub const fn execution_profile(&self) -> ExecutionProfile {
4264        self.execution_profile
4265    }
4266
4267    #[cfg(feature = "sql")]
4268    pub const fn sql_write_profiler(&self) -> Option<&SqlWriteProfiler> {
4269        self.sql_write_profiler.as_ref()
4270    }
4271
4272    #[cfg(feature = "sql")]
4273    pub const fn sql_group_commit_queue_capacity(&self) -> usize {
4274        self.sql_group_commit_queue_capacity
4275    }
4276}
4277
4278fn validate_node_identity(
4279    cluster_id: &str,
4280    node_id: &str,
4281    data_dir: &Path,
4282    epoch: u64,
4283    config_id: u64,
4284) -> Result<(), ConfigError> {
4285    if cluster_id.trim().is_empty() {
4286        return Err(ConfigError::EmptyClusterId);
4287    }
4288    if node_id.trim().is_empty() {
4289        return Err(ConfigError::EmptyNodeId);
4290    }
4291    if data_dir.as_os_str().is_empty() {
4292        return Err(ConfigError::EmptyDataDir);
4293    }
4294    if epoch == 0 {
4295        return Err(ConfigError::InvalidEpoch);
4296    }
4297    if config_id == 0 {
4298        return Err(ConfigError::InvalidConfigId);
4299    }
4300    Ok(())
4301}
4302
4303fn membership_from_node_ids(members: Vec<String>) -> Result<Membership, ConfigError> {
4304    if !(3..=7).contains(&members.len()) {
4305        return Err(ConfigError::InvalidPeerCount(members.len()));
4306    }
4307    Membership::from_voters(members.clone()).map_err(|error| match error {
4308        rhiza_quepaxa::Error::DuplicateRecorderIdentity => {
4309            let duplicate = members
4310                .iter()
4311                .find(|candidate| {
4312                    members
4313                        .iter()
4314                        .filter(|member| *member == *candidate)
4315                        .count()
4316                        > 1
4317                })
4318                .cloned()
4319                .unwrap_or_default();
4320            ConfigError::DuplicatePeerNodeId(duplicate)
4321        }
4322        rhiza_quepaxa::Error::EmptyRecorderIdentity => ConfigError::EmptyPeerNodeId,
4323        _ => ConfigError::InvalidPeerCount(members.len()),
4324    })
4325}
4326
4327fn membership_from_peers(peers: &[PeerConfig]) -> Result<Membership, ConfigError> {
4328    membership_from_node_ids(peers.iter().map(|peer| peer.node_id.clone()).collect())
4329}
4330
4331#[derive(Clone, Debug, Eq, PartialEq)]
4332pub enum NodeError {
4333    UnsupportedAckMode(AckMode),
4334    ExecutionProfileMismatch {
4335        expected: ExecutionProfile,
4336        actual: ExecutionProfile,
4337    },
4338    DataRootLocked(PathBuf),
4339    SnapshotRequired(Box<RecoveryAnchor>),
4340    Storage(String),
4341    Reconciliation(String),
4342    Invariant(String),
4343    Unavailable(String),
4344    ResourceExhausted(String),
4345    ConfigurationTransition {
4346        state: Box<ConfigurationState>,
4347    },
4348    Contention(String),
4349    WinnerLimitExceeded,
4350    #[cfg(feature = "sql")]
4351    RequestConflict(RequestConflict),
4352    InvalidRequest(String),
4353    #[cfg(feature = "sql")]
4354    InvalidSqlStatement {
4355        statement_index: usize,
4356        message: String,
4357    },
4358    PreconditionFailed(String),
4359    Fatal(String),
4360}
4361
4362impl fmt::Display for NodeError {
4363    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4364        match self {
4365            Self::UnsupportedAckMode(mode) => {
4366                write!(
4367                    f,
4368                    "ack mode {mode:?} is unsupported without synchronous archive"
4369                )
4370            }
4371            Self::ExecutionProfileMismatch { expected, actual } => write!(
4372                f,
4373                "execution profile mismatch: expected {expected}, got {actual}"
4374            ),
4375            Self::DataRootLocked(path) => {
4376                write!(f, "node data root is already owned: {}", path.display())
4377            }
4378            Self::SnapshotRequired(anchor) => write!(
4379                f,
4380                "snapshot restore required at qlog anchor {}",
4381                anchor.compacted().index()
4382            ),
4383            Self::Storage(message) => write!(f, "node storage failed: {message}"),
4384            Self::Reconciliation(message) => write!(f, "node reconciliation failed: {message}"),
4385            Self::Invariant(message) => write!(f, "node invariant failed: {message}"),
4386            Self::Unavailable(message) => write!(f, "node unavailable: {message}"),
4387            Self::ResourceExhausted(message) => {
4388                write!(f, "node query resources exhausted: {message}")
4389            }
4390            Self::ConfigurationTransition { state } => write!(
4391                f,
4392                "node unavailable during configuration transition: {state:?}"
4393            ),
4394            Self::Contention(message) => write!(f, "node contention: {message}"),
4395            Self::WinnerLimitExceeded => write!(f, "foreign winner retry limit exceeded"),
4396            #[cfg(feature = "sql")]
4397            Self::RequestConflict(conflict) => conflict.fmt(f),
4398            Self::InvalidRequest(message) => write!(f, "invalid request: {message}"),
4399            #[cfg(feature = "sql")]
4400            Self::InvalidSqlStatement {
4401                statement_index,
4402                message,
4403            } => write!(
4404                f,
4405                "invalid SQL statement at index {statement_index}: {message}"
4406            ),
4407            Self::PreconditionFailed(message) => write!(f, "precondition failed: {message}"),
4408            Self::Fatal(message) => write!(f, "node is fatally unavailable: {message}"),
4409        }
4410    }
4411}
4412
4413impl std::error::Error for NodeError {}
4414
4415impl NodeError {
4416    pub fn classification(&self) -> ErrorClassification {
4417        let (code, retryable) = match self {
4418            Self::InvalidRequest(_) => ("invalid_request", false),
4419            #[cfg(feature = "sql")]
4420            Self::InvalidSqlStatement { .. } => ("invalid_request", false),
4421            #[cfg(feature = "sql")]
4422            Self::RequestConflict(_) => ("request_conflict", false),
4423            Self::PreconditionFailed(_) => ("precondition_failed", false),
4424            Self::SnapshotRequired(_) => ("snapshot_required", false),
4425            Self::Unavailable(_) => ("unavailable", true),
4426            Self::ResourceExhausted(_) => ("resource_exhausted", true),
4427            Self::ConfigurationTransition { .. } => ("configuration_transition", true),
4428            Self::Contention(_) => ("contention", true),
4429            Self::WinnerLimitExceeded => ("winner_limit_exceeded", true),
4430            Self::DataRootLocked(_) => ("data_root_locked", false),
4431            Self::UnsupportedAckMode(_) => ("unsupported_ack_mode", false),
4432            Self::ExecutionProfileMismatch { .. } => ("execution_profile_mismatch", false),
4433            Self::Storage(_) => ("storage_error", false),
4434            Self::Reconciliation(_) => ("reconciliation_error", false),
4435            Self::Invariant(_) => ("invariant_violation", false),
4436            Self::Fatal(_) => ("fatal", false),
4437        };
4438        ErrorClassification::from_server_code(code, retryable)
4439    }
4440}
4441
4442pub type RuntimeError = NodeError;
4443
4444#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4445#[serde(rename_all = "snake_case")]
4446pub enum RuntimeConfigurationStatus {
4447    Active,
4448    Stopped,
4449    AwaitingActivation,
4450}
4451
4452#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4453pub struct NodeStatus {
4454    pub ready: bool,
4455    pub configuration_status: RuntimeConfigurationStatus,
4456    pub configuration_state: ConfigurationState,
4457    pub stop_anchor: Option<rhiza_core::LogAnchor>,
4458    pub active_config_id: u64,
4459    pub active_membership_digest: LogHash,
4460}
4461
4462#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4463pub struct StopInformation {
4464    pub version: u16,
4465    pub entry: LogEntry,
4466    pub proof: DecisionProof,
4467}
4468
4469#[cfg(feature = "sql")]
4470#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4471#[serde(deny_unknown_fields)]
4472pub struct WriteRequest {
4473    pub request_id: String,
4474    pub key: String,
4475    pub value: String,
4476}
4477
4478#[cfg(feature = "sql")]
4479#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4480pub struct WriteResponse {
4481    pub applied_index: LogIndex,
4482    pub hash: LogHash,
4483}
4484
4485#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4486pub struct ClientErrorResponse {
4487    pub code: String,
4488    pub retryable: bool,
4489    pub message: String,
4490    #[serde(default, skip_serializing_if = "Option::is_none")]
4491    pub statement_index: Option<usize>,
4492}
4493
4494#[cfg(feature = "sql")]
4495#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4496#[serde(deny_unknown_fields)]
4497pub struct ReadRequest {
4498    pub key: String,
4499    pub consistency: Option<ReadConsistency>,
4500}
4501
4502#[cfg(feature = "sql")]
4503#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4504pub struct ReadResponse {
4505    pub value: Option<String>,
4506    pub applied_index: LogIndex,
4507    pub hash: LogHash,
4508}
4509
4510#[cfg(feature = "sql")]
4511#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4512#[serde(deny_unknown_fields)]
4513pub struct SqlExecuteRequest {
4514    pub request_id: String,
4515    pub statements: Vec<SqlStatement>,
4516}
4517
4518#[cfg(feature = "sql")]
4519#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4520pub struct SqlExecuteResponse {
4521    pub version: u16,
4522    pub applied_index: LogIndex,
4523    pub hash: LogHash,
4524    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4525    pub results: Vec<SqlStatementResult>,
4526}
4527
4528#[cfg(feature = "sql")]
4529impl From<WriteResponse> for SqlExecuteResponse {
4530    fn from(response: WriteResponse) -> Self {
4531        sql_execute_response(response, None)
4532    }
4533}
4534
4535#[cfg(feature = "sql")]
4536fn sql_execute_response(
4537    response: WriteResponse,
4538    result: Option<SqlCommandResult>,
4539) -> SqlExecuteResponse {
4540    let results = result
4541        .map(|result| {
4542            result
4543                .statement_results
4544                .into_iter()
4545                .enumerate()
4546                .map(|(statement_index, result)| SqlStatementResult {
4547                    statement_index,
4548                    rows_affected: result.rows_affected,
4549                    returning: result.returning,
4550                })
4551                .collect()
4552        })
4553        .unwrap_or_default();
4554    SqlExecuteResponse {
4555        version: SQL_EXECUTE_RESPONSE_VERSION,
4556        applied_index: response.applied_index,
4557        hash: response.hash,
4558        results,
4559    }
4560}
4561
4562#[cfg(feature = "sql")]
4563#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4564pub struct SqlStatementResult {
4565    pub statement_index: usize,
4566    pub rows_affected: u64,
4567    #[serde(default, skip_serializing_if = "Option::is_none")]
4568    pub returning: Option<SqlQueryResult>,
4569}
4570
4571#[cfg(feature = "sql")]
4572#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4573#[serde(deny_unknown_fields)]
4574pub struct SqlQueryRequest {
4575    pub statement: SqlStatement,
4576    pub consistency: Option<ReadConsistency>,
4577    pub max_rows: Option<u32>,
4578}
4579
4580#[cfg(feature = "sql")]
4581#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4582pub struct SqlQueryResponse {
4583    pub columns: Vec<String>,
4584    pub rows: Vec<Vec<SqlValue>>,
4585    pub applied_index: LogIndex,
4586    pub hash: LogHash,
4587}
4588
4589#[cfg(feature = "graph")]
4590#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4591#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4592pub enum GraphValueDto {
4593    Null,
4594    Bool(bool),
4595    I64(i64),
4596    U64(u64),
4597    F64(f64),
4598    String(String),
4599    Bytes(String),
4600}
4601
4602#[cfg(feature = "graph")]
4603#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4604#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4605pub enum GraphQueryParameterDto {
4606    Null,
4607    Bool(bool),
4608    I64(i64),
4609    U64(u64),
4610    F64(f64),
4611    String(String),
4612    Bytes(String),
4613    List(Vec<Self>),
4614    Struct(BTreeMap<String, Self>),
4615}
4616
4617#[cfg(feature = "graph")]
4618impl TryFrom<GraphQueryParameterDto> for GraphParameterValue {
4619    type Error = NodeError;
4620
4621    fn try_from(value: GraphQueryParameterDto) -> Result<Self, Self::Error> {
4622        Ok(match value {
4623            GraphQueryParameterDto::Null => Self::Null,
4624            GraphQueryParameterDto::Bool(value) => Self::Bool(value),
4625            GraphQueryParameterDto::I64(value) => Self::I64(value),
4626            GraphQueryParameterDto::U64(value) => Self::U64(value),
4627            GraphQueryParameterDto::F64(value) => Self::F64(
4628                CanonicalF64::new(value)
4629                    .map_err(|error| NodeError::InvalidRequest(error.to_string()))?,
4630            ),
4631            GraphQueryParameterDto::String(value) => Self::String(value),
4632            GraphQueryParameterDto::Bytes(value) => {
4633                Self::Bytes(decode_base64("graph parameter bytes", &value)?)
4634            }
4635            GraphQueryParameterDto::List(values) => Self::List(
4636                values
4637                    .into_iter()
4638                    .map(Self::try_from)
4639                    .collect::<Result<_, _>>()?,
4640            ),
4641            GraphQueryParameterDto::Struct(values) => Self::Struct(
4642                values
4643                    .into_iter()
4644                    .map(|(name, value)| Self::try_from(value).map(|value| (name, value)))
4645                    .collect::<Result<_, _>>()?,
4646            ),
4647        })
4648    }
4649}
4650
4651#[cfg(feature = "graph")]
4652#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4653#[serde(deny_unknown_fields)]
4654pub struct GraphQueryStatementDto {
4655    pub cypher: String,
4656    #[serde(default)]
4657    pub parameters: BTreeMap<String, GraphQueryParameterDto>,
4658}
4659
4660#[cfg(feature = "graph")]
4661#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4662#[serde(deny_unknown_fields)]
4663pub struct GraphQueryRequest {
4664    pub statement: GraphQueryStatementDto,
4665    pub consistency: Option<ReadConsistency>,
4666    pub max_rows: Option<u32>,
4667}
4668
4669#[cfg(feature = "graph")]
4670#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4671pub struct GraphInternalIdDto {
4672    pub offset: u64,
4673    pub table_id: u64,
4674}
4675
4676#[cfg(feature = "graph")]
4677impl From<GraphInternalId> for GraphInternalIdDto {
4678    fn from(value: GraphInternalId) -> Self {
4679        Self {
4680            offset: value.offset,
4681            table_id: value.table_id,
4682        }
4683    }
4684}
4685
4686#[cfg(feature = "graph")]
4687#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4688pub struct GraphNamedValueDto {
4689    pub name: String,
4690    pub value: GraphResultValueDto,
4691}
4692
4693#[cfg(feature = "graph")]
4694#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4695pub struct GraphNodeDto {
4696    pub id: GraphInternalIdDto,
4697    pub label: String,
4698    pub properties: Vec<GraphNamedValueDto>,
4699}
4700
4701#[cfg(feature = "graph")]
4702impl From<GraphNode> for GraphNodeDto {
4703    fn from(value: GraphNode) -> Self {
4704        Self {
4705            id: value.id.into(),
4706            label: value.label,
4707            properties: named_graph_values(value.properties),
4708        }
4709    }
4710}
4711
4712#[cfg(feature = "graph")]
4713#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4714pub struct GraphRelDto {
4715    pub src: GraphInternalIdDto,
4716    pub dst: GraphInternalIdDto,
4717    pub label: String,
4718    pub properties: Vec<GraphNamedValueDto>,
4719}
4720
4721#[cfg(feature = "graph")]
4722impl From<GraphRel> for GraphRelDto {
4723    fn from(value: GraphRel) -> Self {
4724        Self {
4725            src: value.src.into(),
4726            dst: value.dst.into(),
4727            label: value.label,
4728            properties: named_graph_values(value.properties),
4729        }
4730    }
4731}
4732
4733#[cfg(feature = "graph")]
4734#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4735pub struct GraphRecursiveRelDto {
4736    pub nodes: Vec<GraphNodeDto>,
4737    pub rels: Vec<GraphRelDto>,
4738}
4739
4740#[cfg(feature = "graph")]
4741#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4742pub struct GraphMapEntryDto {
4743    pub key: GraphResultValueDto,
4744    pub value: GraphResultValueDto,
4745}
4746
4747#[cfg(feature = "graph")]
4748#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4749pub struct GraphNamedLogicalTypeDto {
4750    pub name: String,
4751    pub logical_type: GraphLogicalTypeDto,
4752}
4753
4754#[cfg(feature = "graph")]
4755#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4756pub struct GraphArrayTypeDto {
4757    pub element_type: Box<GraphLogicalTypeDto>,
4758    pub length: u64,
4759}
4760
4761#[cfg(feature = "graph")]
4762#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4763pub struct GraphMapTypeDto {
4764    pub key_type: Box<GraphLogicalTypeDto>,
4765    pub value_type: Box<GraphLogicalTypeDto>,
4766}
4767
4768#[cfg(feature = "graph")]
4769#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4770pub struct GraphDecimalTypeDto {
4771    pub precision: u32,
4772    pub scale: u32,
4773}
4774
4775#[cfg(feature = "graph")]
4776#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4777#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4778pub enum GraphLogicalTypeDto {
4779    Any,
4780    Bool,
4781    Serial,
4782    I64,
4783    I32,
4784    I16,
4785    I8,
4786    U64,
4787    U32,
4788    U16,
4789    U8,
4790    I128,
4791    F64,
4792    F32,
4793    Date,
4794    Interval,
4795    Timestamp,
4796    TimestampTz,
4797    TimestampNs,
4798    TimestampMs,
4799    TimestampSec,
4800    InternalId,
4801    String,
4802    Json,
4803    Bytes,
4804    List(Box<Self>),
4805    Array(GraphArrayTypeDto),
4806    Struct(Vec<GraphNamedLogicalTypeDto>),
4807    Node,
4808    Rel,
4809    RecursiveRel,
4810    Map(GraphMapTypeDto),
4811    Union(Vec<GraphNamedLogicalTypeDto>),
4812    Uuid,
4813    Decimal(GraphDecimalTypeDto),
4814}
4815
4816#[cfg(feature = "graph")]
4817impl From<GraphLogicalType> for GraphLogicalTypeDto {
4818    fn from(value: GraphLogicalType) -> Self {
4819        match value {
4820            GraphLogicalType::Any => Self::Any,
4821            GraphLogicalType::Bool => Self::Bool,
4822            GraphLogicalType::Serial => Self::Serial,
4823            GraphLogicalType::I64 => Self::I64,
4824            GraphLogicalType::I32 => Self::I32,
4825            GraphLogicalType::I16 => Self::I16,
4826            GraphLogicalType::I8 => Self::I8,
4827            GraphLogicalType::U64 => Self::U64,
4828            GraphLogicalType::U32 => Self::U32,
4829            GraphLogicalType::U16 => Self::U16,
4830            GraphLogicalType::U8 => Self::U8,
4831            GraphLogicalType::I128 => Self::I128,
4832            GraphLogicalType::F64 => Self::F64,
4833            GraphLogicalType::F32 => Self::F32,
4834            GraphLogicalType::Date => Self::Date,
4835            GraphLogicalType::Interval => Self::Interval,
4836            GraphLogicalType::Timestamp => Self::Timestamp,
4837            GraphLogicalType::TimestampTz => Self::TimestampTz,
4838            GraphLogicalType::TimestampNs => Self::TimestampNs,
4839            GraphLogicalType::TimestampMs => Self::TimestampMs,
4840            GraphLogicalType::TimestampSec => Self::TimestampSec,
4841            GraphLogicalType::InternalId => Self::InternalId,
4842            GraphLogicalType::String => Self::String,
4843            GraphLogicalType::Json => Self::Json,
4844            GraphLogicalType::Bytes => Self::Bytes,
4845            GraphLogicalType::List(element_type) => Self::List(Box::new((*element_type).into())),
4846            GraphLogicalType::Array {
4847                element_type,
4848                length,
4849            } => Self::Array(GraphArrayTypeDto {
4850                element_type: Box::new((*element_type).into()),
4851                length,
4852            }),
4853            GraphLogicalType::Struct(fields) => Self::Struct(named_graph_logical_types(fields)),
4854            GraphLogicalType::Node => Self::Node,
4855            GraphLogicalType::Rel => Self::Rel,
4856            GraphLogicalType::RecursiveRel => Self::RecursiveRel,
4857            GraphLogicalType::Map {
4858                key_type,
4859                value_type,
4860            } => Self::Map(GraphMapTypeDto {
4861                key_type: Box::new((*key_type).into()),
4862                value_type: Box::new((*value_type).into()),
4863            }),
4864            GraphLogicalType::Union(types) => Self::Union(named_graph_logical_types(types)),
4865            GraphLogicalType::Uuid => Self::Uuid,
4866            GraphLogicalType::Decimal { precision, scale } => {
4867                Self::Decimal(GraphDecimalTypeDto { precision, scale })
4868            }
4869        }
4870    }
4871}
4872
4873#[cfg(feature = "graph")]
4874#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4875pub struct GraphCollectionValueDto {
4876    pub element_type: GraphLogicalTypeDto,
4877    pub values: Vec<GraphResultValueDto>,
4878}
4879
4880#[cfg(feature = "graph")]
4881#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4882pub struct GraphMapValueDto {
4883    pub key_type: GraphLogicalTypeDto,
4884    pub value_type: GraphLogicalTypeDto,
4885    pub entries: Vec<GraphMapEntryDto>,
4886}
4887
4888#[cfg(feature = "graph")]
4889#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4890pub struct GraphUnionValueDto {
4891    pub variants: Vec<GraphNamedLogicalTypeDto>,
4892    pub value: Box<GraphResultValueDto>,
4893}
4894
4895#[cfg(feature = "graph")]
4896#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4897#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4898pub enum GraphResultValueDto {
4899    Null(GraphLogicalTypeDto),
4900    Bool(bool),
4901    I64(i64),
4902    I32(i32),
4903    I16(i16),
4904    I8(i8),
4905    U64(u64),
4906    U32(u32),
4907    U16(u16),
4908    U8(u8),
4909    I128(String),
4910    F64(f64),
4911    F32(String),
4912    Date(String),
4913    Interval(String),
4914    Timestamp(String),
4915    TimestampTz(String),
4916    TimestampNs(String),
4917    TimestampMs(String),
4918    TimestampSec(String),
4919    InternalId(GraphInternalIdDto),
4920    String(String),
4921    Json(String),
4922    Bytes(String),
4923    List(GraphCollectionValueDto),
4924    Array(GraphCollectionValueDto),
4925    Struct(Vec<GraphNamedValueDto>),
4926    Node(GraphNodeDto),
4927    Rel(GraphRelDto),
4928    RecursiveRel(GraphRecursiveRelDto),
4929    Map(GraphMapValueDto),
4930    Union(GraphUnionValueDto),
4931    Uuid(String),
4932    Decimal(String),
4933}
4934
4935#[cfg(feature = "graph")]
4936impl From<GraphResultValue> for GraphResultValueDto {
4937    fn from(value: GraphResultValue) -> Self {
4938        match value {
4939            GraphResultValue::Null(value) => Self::Null(value.into()),
4940            GraphResultValue::Bool(value) => Self::Bool(value),
4941            GraphResultValue::I64(value) => Self::I64(value),
4942            GraphResultValue::I32(value) => Self::I32(value),
4943            GraphResultValue::I16(value) => Self::I16(value),
4944            GraphResultValue::I8(value) => Self::I8(value),
4945            GraphResultValue::U64(value) => Self::U64(value),
4946            GraphResultValue::U32(value) => Self::U32(value),
4947            GraphResultValue::U16(value) => Self::U16(value),
4948            GraphResultValue::U8(value) => Self::U8(value),
4949            GraphResultValue::I128(value) => Self::I128(value),
4950            GraphResultValue::F64(value) => Self::F64(value.get()),
4951            GraphResultValue::F32(value) => Self::F32(value),
4952            GraphResultValue::Date(value) => Self::Date(value),
4953            GraphResultValue::Interval(value) => Self::Interval(value),
4954            GraphResultValue::Timestamp(value) => Self::Timestamp(value),
4955            GraphResultValue::TimestampTz(value) => Self::TimestampTz(value),
4956            GraphResultValue::TimestampNs(value) => Self::TimestampNs(value),
4957            GraphResultValue::TimestampMs(value) => Self::TimestampMs(value),
4958            GraphResultValue::TimestampSec(value) => Self::TimestampSec(value),
4959            GraphResultValue::InternalId(value) => Self::InternalId(value.into()),
4960            GraphResultValue::String(value) => Self::String(value),
4961            GraphResultValue::Json(value) => Self::Json(value),
4962            GraphResultValue::Bytes(value) => Self::Bytes(encode_base64(&value)),
4963            GraphResultValue::List {
4964                element_type,
4965                values,
4966            } => Self::List(GraphCollectionValueDto {
4967                element_type: element_type.into(),
4968                values: values.into_iter().map(Self::from).collect(),
4969            }),
4970            GraphResultValue::Array {
4971                element_type,
4972                values,
4973            } => Self::Array(GraphCollectionValueDto {
4974                element_type: element_type.into(),
4975                values: values.into_iter().map(Self::from).collect(),
4976            }),
4977            GraphResultValue::Struct(values) => Self::Struct(named_graph_values(values)),
4978            GraphResultValue::Node(value) => Self::Node(value.into()),
4979            GraphResultValue::Rel(value) => Self::Rel(value.into()),
4980            GraphResultValue::RecursiveRel { nodes, rels } => {
4981                Self::RecursiveRel(GraphRecursiveRelDto {
4982                    nodes: nodes.into_iter().map(Into::into).collect(),
4983                    rels: rels.into_iter().map(Into::into).collect(),
4984                })
4985            }
4986            GraphResultValue::Map {
4987                key_type,
4988                value_type,
4989                entries,
4990            } => Self::Map(GraphMapValueDto {
4991                key_type: key_type.into(),
4992                value_type: value_type.into(),
4993                entries: entries
4994                    .into_iter()
4995                    .map(|(key, value)| GraphMapEntryDto {
4996                        key: key.into(),
4997                        value: value.into(),
4998                    })
4999                    .collect(),
5000            }),
5001            GraphResultValue::Union { variants, value } => Self::Union(GraphUnionValueDto {
5002                variants: named_graph_logical_types(variants),
5003                value: Box::new(Self::from(*value)),
5004            }),
5005            GraphResultValue::Uuid(value) => Self::Uuid(value),
5006            GraphResultValue::Decimal(value) => Self::Decimal(value),
5007        }
5008    }
5009}
5010
5011#[cfg(feature = "graph")]
5012fn named_graph_values(values: Vec<(String, GraphResultValue)>) -> Vec<GraphNamedValueDto> {
5013    values
5014        .into_iter()
5015        .map(|(name, value)| GraphNamedValueDto {
5016            name,
5017            value: value.into(),
5018        })
5019        .collect()
5020}
5021
5022#[cfg(feature = "graph")]
5023fn named_graph_logical_types(
5024    values: Vec<(String, GraphLogicalType)>,
5025) -> Vec<GraphNamedLogicalTypeDto> {
5026    values
5027        .into_iter()
5028        .map(|(name, logical_type)| GraphNamedLogicalTypeDto {
5029            name,
5030            logical_type: logical_type.into(),
5031        })
5032        .collect()
5033}
5034
5035#[cfg(feature = "graph")]
5036#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5037pub struct GraphColumnDto {
5038    pub name: String,
5039    pub logical_type: GraphLogicalTypeDto,
5040}
5041
5042#[cfg(feature = "graph")]
5043impl From<GraphColumn> for GraphColumnDto {
5044    fn from(value: GraphColumn) -> Self {
5045        Self {
5046            name: value.name,
5047            logical_type: value.logical_type.into(),
5048        }
5049    }
5050}
5051
5052#[cfg(feature = "graph")]
5053#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5054pub struct GraphQueryResponse {
5055    pub columns: Vec<GraphColumnDto>,
5056    pub rows: Vec<Vec<GraphResultValueDto>>,
5057    pub applied_index: LogIndex,
5058    pub hash: LogHash,
5059}
5060
5061#[cfg(feature = "graph")]
5062impl From<GraphQueryResult> for GraphQueryResponse {
5063    fn from(value: GraphQueryResult) -> Self {
5064        Self {
5065            columns: value.columns.into_iter().map(Into::into).collect(),
5066            rows: value
5067                .rows
5068                .into_iter()
5069                .map(|row| row.into_iter().map(Into::into).collect())
5070                .collect(),
5071            applied_index: value.applied_index,
5072            hash: value.hash,
5073        }
5074    }
5075}
5076
5077#[cfg(feature = "graph")]
5078#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5079#[serde(deny_unknown_fields)]
5080pub struct GraphPutDocumentRequest {
5081    pub request_id: String,
5082    pub id: String,
5083    pub value: GraphValueDto,
5084}
5085
5086#[cfg(feature = "graph")]
5087#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5088#[serde(deny_unknown_fields)]
5089pub struct GraphDeleteDocumentRequest {
5090    pub request_id: String,
5091    pub id: String,
5092}
5093
5094#[cfg(feature = "graph")]
5095#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5096#[serde(deny_unknown_fields)]
5097pub struct GraphGetDocumentRequest {
5098    pub id: String,
5099    pub consistency: Option<ReadConsistency>,
5100}
5101
5102#[cfg(feature = "graph")]
5103#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5104#[serde(tag = "operation", rename_all = "snake_case")]
5105pub enum GraphMutationResultDto {
5106    PutDocument { created: bool },
5107    DeleteDocument { existed: bool },
5108}
5109
5110#[cfg(feature = "graph")]
5111#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5112pub struct GraphMutationResponse {
5113    pub applied_index: LogIndex,
5114    pub hash: LogHash,
5115    pub result: GraphMutationResultDto,
5116}
5117
5118#[cfg(feature = "graph")]
5119#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5120pub struct GraphGetDocumentResponse {
5121    pub value: Option<GraphValueDto>,
5122    pub applied_index: LogIndex,
5123    pub hash: LogHash,
5124}
5125
5126#[cfg(feature = "kv")]
5127#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5128#[serde(deny_unknown_fields)]
5129pub struct KvPutRequest {
5130    pub request_id: String,
5131    pub key: String,
5132    pub value: String,
5133}
5134
5135#[cfg(feature = "kv")]
5136#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5137#[serde(deny_unknown_fields)]
5138pub struct KvDeleteRequest {
5139    pub request_id: String,
5140    pub key: String,
5141}
5142
5143#[cfg(feature = "kv")]
5144#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5145#[serde(deny_unknown_fields)]
5146pub struct KvGetRequest {
5147    pub key: String,
5148    pub consistency: Option<ReadConsistency>,
5149}
5150
5151#[cfg(feature = "kv")]
5152#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5153#[serde(deny_unknown_fields)]
5154pub struct KvScanRequest {
5155    pub start: Option<String>,
5156    pub end: Option<String>,
5157    pub prefix: Option<String>,
5158    pub cursor: Option<String>,
5159    pub limit: Option<usize>,
5160    pub consistency: Option<ReadConsistency>,
5161}
5162
5163#[cfg(feature = "kv")]
5164#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5165#[serde(tag = "operation", rename_all = "snake_case")]
5166pub enum KvMutationResultDto {
5167    Put { replaced: bool },
5168    Delete { existed: bool },
5169}
5170
5171#[cfg(feature = "kv")]
5172#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5173pub struct KvMutationResponse {
5174    pub applied_index: LogIndex,
5175    pub hash: LogHash,
5176    pub result: KvMutationResultDto,
5177}
5178
5179#[cfg(feature = "kv")]
5180#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5181pub struct KvGetResponse {
5182    pub value: Option<String>,
5183    pub applied_index: LogIndex,
5184    pub hash: LogHash,
5185}
5186
5187#[cfg(feature = "kv")]
5188#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5189pub struct KvScanEntryDto {
5190    pub key: String,
5191    pub value: String,
5192}
5193
5194#[cfg(feature = "kv")]
5195#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5196pub struct KvScanResponse {
5197    pub entries: Vec<KvScanEntryDto>,
5198    pub next_cursor: Option<String>,
5199    pub applied_index: LogIndex,
5200    pub hash: LogHash,
5201}
5202
5203#[cfg(feature = "graph")]
5204fn graph_mutation_response(outcome: GraphMutationOutcome) -> GraphMutationResponse {
5205    GraphMutationResponse {
5206        applied_index: outcome.applied_index(),
5207        hash: outcome.hash(),
5208        result: match outcome.result() {
5209            GraphCommandResultV1::PutDocument { created } => {
5210                GraphMutationResultDto::PutDocument { created: *created }
5211            }
5212            GraphCommandResultV1::DeleteDocument { existed } => {
5213                GraphMutationResultDto::DeleteDocument { existed: *existed }
5214            }
5215        },
5216    }
5217}
5218
5219#[cfg(feature = "kv")]
5220fn kv_mutation_response(outcome: KvMutationOutcome) -> KvMutationResponse {
5221    KvMutationResponse {
5222        applied_index: outcome.applied_index(),
5223        hash: outcome.hash(),
5224        result: match outcome.result() {
5225            KvCommandResultV1::Put { replaced } => KvMutationResultDto::Put {
5226                replaced: *replaced,
5227            },
5228            KvCommandResultV1::Delete { existed } => {
5229                KvMutationResultDto::Delete { existed: *existed }
5230            }
5231        },
5232    }
5233}
5234
5235#[cfg(feature = "kv")]
5236fn validate_kv_scan_required_index(
5237    result: &KvScanResult,
5238    required_index: Option<LogIndex>,
5239) -> Result<(), NodeError> {
5240    let applied_index = result.tip().applied_index();
5241    if required_index.is_some_and(|required| applied_index < required) {
5242        return Err(NodeError::Unavailable(format!(
5243            "local applied index {applied_index} has not reached {}",
5244            required_index.expect("checked above")
5245        )));
5246    }
5247    Ok(())
5248}
5249
5250#[cfg(feature = "graph")]
5251impl TryFrom<GraphValueDto> for GraphValueV1 {
5252    type Error = NodeError;
5253
5254    fn try_from(value: GraphValueDto) -> Result<Self, Self::Error> {
5255        match value {
5256            GraphValueDto::Null => Ok(Self::Null),
5257            GraphValueDto::Bool(value) => Ok(Self::Bool(value)),
5258            GraphValueDto::I64(value) => Ok(Self::I64(value)),
5259            GraphValueDto::U64(value) => Ok(Self::U64(value)),
5260            GraphValueDto::F64(value) => {
5261                Self::from_f64(value).map_err(|error| NodeError::InvalidRequest(error.to_string()))
5262            }
5263            GraphValueDto::String(value) => Ok(Self::String(value)),
5264            GraphValueDto::Bytes(value) => decode_base64("value", &value).map(Self::Bytes),
5265        }
5266    }
5267}
5268
5269#[cfg(feature = "graph")]
5270impl From<GraphValueV1> for GraphValueDto {
5271    fn from(value: GraphValueV1) -> Self {
5272        match value {
5273            GraphValueV1::Null => Self::Null,
5274            GraphValueV1::Bool(value) => Self::Bool(value),
5275            GraphValueV1::I64(value) => Self::I64(value),
5276            GraphValueV1::U64(value) => Self::U64(value),
5277            GraphValueV1::F64(value) => Self::F64(value.get()),
5278            GraphValueV1::String(value) => Self::String(value),
5279            GraphValueV1::Bytes(value) => Self::Bytes(encode_base64(&value)),
5280        }
5281    }
5282}
5283
5284#[cfg(any(feature = "graph", feature = "kv"))]
5285fn encode_base64(bytes: &[u8]) -> String {
5286    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
5287    let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4);
5288    for chunk in bytes.chunks(3) {
5289        let first = chunk[0];
5290        let second = chunk.get(1).copied().unwrap_or(0);
5291        let third = chunk.get(2).copied().unwrap_or(0);
5292        encoded.push(ALPHABET[usize::from(first >> 2)] as char);
5293        encoded.push(ALPHABET[usize::from(((first & 0x03) << 4) | (second >> 4))] as char);
5294        if chunk.len() > 1 {
5295            encoded.push(ALPHABET[usize::from(((second & 0x0f) << 2) | (third >> 6))] as char);
5296        } else {
5297            encoded.push('=');
5298        }
5299        if chunk.len() > 2 {
5300            encoded.push(ALPHABET[usize::from(third & 0x3f)] as char);
5301        } else {
5302            encoded.push('=');
5303        }
5304    }
5305    encoded
5306}
5307
5308#[cfg(any(feature = "graph", feature = "kv"))]
5309fn decode_base64(field: &str, encoded: &str) -> Result<Vec<u8>, NodeError> {
5310    fn sextet(byte: u8) -> Option<u8> {
5311        match byte {
5312            b'A'..=b'Z' => Some(byte - b'A'),
5313            b'a'..=b'z' => Some(byte - b'a' + 26),
5314            b'0'..=b'9' => Some(byte - b'0' + 52),
5315            b'+' => Some(62),
5316            b'/' => Some(63),
5317            _ => None,
5318        }
5319    }
5320
5321    let bytes = encoded.as_bytes();
5322    if !bytes.len().is_multiple_of(4) {
5323        return Err(NodeError::InvalidRequest(format!(
5324            "{field} must be canonical padded base64"
5325        )));
5326    }
5327    let mut decoded = Vec::with_capacity(bytes.len() / 4 * 3);
5328    for (chunk_index, chunk) in bytes.chunks_exact(4).enumerate() {
5329        let last = chunk_index + 1 == bytes.len() / 4;
5330        let first = sextet(chunk[0]);
5331        let second = sextet(chunk[1]);
5332        let third = (chunk[2] != b'=').then(|| sextet(chunk[2])).flatten();
5333        let fourth = (chunk[3] != b'=').then(|| sextet(chunk[3])).flatten();
5334        let has_padding = chunk[2] == b'=' || chunk[3] == b'=';
5335        if first.is_none()
5336            || second.is_none()
5337            || (chunk[2] != b'=' && third.is_none())
5338            || (chunk[3] != b'=' && fourth.is_none())
5339            || (!last && has_padding)
5340            || (chunk[2] == b'=' && chunk[3] != b'=')
5341        {
5342            return Err(NodeError::InvalidRequest(format!(
5343                "{field} must be canonical padded base64"
5344            )));
5345        }
5346        let first = first.unwrap();
5347        let second = second.unwrap();
5348        decoded.push((first << 2) | (second >> 4));
5349        if let Some(third) = third {
5350            decoded.push((second << 4) | (third >> 2));
5351            if let Some(fourth) = fourth {
5352                decoded.push((third << 6) | fourth);
5353            } else if third & 0x03 != 0 {
5354                return Err(NodeError::InvalidRequest(format!(
5355                    "{field} must be canonical padded base64"
5356                )));
5357            }
5358        } else if second & 0x0f != 0 {
5359            return Err(NodeError::InvalidRequest(format!(
5360                "{field} must be canonical padded base64"
5361            )));
5362        }
5363    }
5364    Ok(decoded)
5365}
5366
5367enum Materializer {
5368    #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5369    Unavailable,
5370    #[cfg(feature = "sql")]
5371    Sql(Box<SqliteStateMachine>),
5372    #[cfg(feature = "graph")]
5373    Graph(Arc<LadybugStateMachine>),
5374    #[cfg(feature = "kv")]
5375    Kv(Arc<RedbStateMachine>),
5376}
5377
5378#[cfg(any(feature = "sql", feature = "kv"))]
5379fn quarantine_materializer(data_dir: &Path, directory: &str) -> Result<(), NodeError> {
5380    static SEQUENCE: AtomicUsize = AtomicUsize::new(0);
5381    let source = data_dir.join(directory);
5382    if !source.exists() {
5383        return Ok(());
5384    }
5385    loop {
5386        let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
5387        let target = data_dir.join(format!(
5388            "{directory}.quarantine-{}-{sequence}",
5389            std::process::id()
5390        ));
5391        match fs::rename(&source, target) {
5392            Ok(()) => return Ok(()),
5393            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
5394            Err(error) => return Err(NodeError::Storage(error.to_string())),
5395        }
5396    }
5397}
5398
5399#[cfg(feature = "sql")]
5400struct SqlMaterializerGuard<'a>(MutexGuard<'a, Materializer>);
5401
5402#[cfg(feature = "sql")]
5403impl std::ops::Deref for SqlMaterializerGuard<'_> {
5404    type Target = SqliteStateMachine;
5405
5406    fn deref(&self) -> &Self::Target {
5407        match &*self.0 {
5408            Materializer::Sql(state) => state,
5409            #[cfg(feature = "graph")]
5410            Materializer::Graph(_) => unreachable!("SQL guard validated the materializer profile"),
5411            #[cfg(feature = "kv")]
5412            Materializer::Kv(_) => unreachable!("SQL guard validated the materializer profile"),
5413        }
5414    }
5415}
5416
5417impl Materializer {
5418    fn ensure_profile_available(profile: ExecutionProfile) -> Result<(), NodeError> {
5419        if execution_profile_compiled(profile) {
5420            Ok(())
5421        } else {
5422            Err(NodeError::Unavailable(format!(
5423                "{} execution profile is not compiled in",
5424                profile.as_str()
5425            )))
5426        }
5427    }
5428
5429    #[cfg_attr(not(feature = "sql"), allow(unused_variables))]
5430    fn open(
5431        config: &NodeConfig,
5432        configuration_state: &ConfigurationState,
5433        recovery_anchor: Option<&RecoveryAnchor>,
5434    ) -> Result<Self, NodeError> {
5435        match config.execution_profile() {
5436            ExecutionProfile::Sqlite => {
5437                #[cfg(feature = "sql")]
5438                {
5439                    let path = config.data_dir().join("sqlite/db.sqlite");
5440                    let open = || {
5441                        SqliteStateMachine::open_with_configuration(
5442                            &path,
5443                            config.cluster_id(),
5444                            config.node_id(),
5445                            config.epoch(),
5446                            configuration_state.clone(),
5447                        )
5448                    };
5449                    let state = match open() {
5450                        Ok(state) => state,
5451                        Err(_) => match recovery_anchor {
5452                            Some(anchor) => {
5453                                return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())))
5454                            }
5455                            None => {
5456                                quarantine_materializer(config.data_dir(), "sqlite")?;
5457                                open().map_err(|error| NodeError::Storage(error.to_string()))?
5458                            }
5459                        },
5460                    };
5461                    Ok(Self::Sql(Box::new(state)))
5462                }
5463                #[cfg(not(feature = "sql"))]
5464                Err(NodeError::Unavailable(
5465                    "sql execution profile is not compiled in".into(),
5466                ))
5467            }
5468            ExecutionProfile::Graph => {
5469                #[cfg(feature = "graph")]
5470                {
5471                    LadybugStateMachine::open(
5472                        config.data_dir().join("ladybug/graph.lbug"),
5473                        config.cluster_id(),
5474                        config.node_id(),
5475                        config.epoch(),
5476                        configuration_state.config_id(),
5477                    )
5478                    .map(Arc::new)
5479                    .map(Self::Graph)
5480                    .map_err(|error| NodeError::Storage(error.to_string()))
5481                }
5482                #[cfg(not(feature = "graph"))]
5483                Err(NodeError::Unavailable(
5484                    "graph execution profile is not compiled in".into(),
5485                ))
5486            }
5487            ExecutionProfile::Kv => {
5488                #[cfg(feature = "kv")]
5489                {
5490                    let open = || {
5491                        RedbStateMachine::open(
5492                            config.data_dir().join("kv/data.redb"),
5493                            config.cluster_id(),
5494                            config.node_id(),
5495                            config.epoch(),
5496                            configuration_state.config_id(),
5497                        )
5498                    };
5499                    let state = match open() {
5500                        Ok(state) => state,
5501                        Err(_) => match recovery_anchor {
5502                            Some(anchor) => {
5503                                return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())))
5504                            }
5505                            _ => {
5506                                quarantine_materializer(config.data_dir(), "kv")?;
5507                                open().map_err(|error| NodeError::Storage(error.to_string()))?
5508                            }
5509                        },
5510                    };
5511                    Ok(Self::Kv(Arc::new(state)))
5512                }
5513                #[cfg(not(feature = "kv"))]
5514                Err(NodeError::Unavailable(
5515                    "kv execution profile is not compiled in".into(),
5516                ))
5517            }
5518        }
5519    }
5520
5521    fn profile(&self) -> ExecutionProfile {
5522        match self {
5523            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5524            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5525            #[cfg(feature = "sql")]
5526            Self::Sql(_) => ExecutionProfile::Sqlite,
5527            #[cfg(feature = "graph")]
5528            Self::Graph(_) => ExecutionProfile::Graph,
5529            #[cfg(feature = "kv")]
5530            Self::Kv(_) => ExecutionProfile::Kv,
5531        }
5532    }
5533
5534    fn applied_index(&self) -> Result<LogIndex, String> {
5535        match self {
5536            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5537            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5538            #[cfg(feature = "sql")]
5539            Self::Sql(state) => state
5540                .applied_index_value()
5541                .map_err(|error| error.to_string()),
5542            #[cfg(feature = "graph")]
5543            Self::Graph(state) => state.applied_index().map_err(|error| error.to_string()),
5544            #[cfg(feature = "kv")]
5545            Self::Kv(state) => state.applied_index().map_err(|error| error.to_string()),
5546        }
5547    }
5548
5549    fn applied_hash(&self) -> Result<LogHash, String> {
5550        match self {
5551            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5552            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5553            #[cfg(feature = "sql")]
5554            Self::Sql(state) => state
5555                .applied_hash_value()
5556                .map_err(|error| error.to_string()),
5557            #[cfg(feature = "graph")]
5558            Self::Graph(state) => state.applied_hash().map_err(|error| error.to_string()),
5559            #[cfg(feature = "kv")]
5560            Self::Kv(state) => state.applied_hash().map_err(|error| error.to_string()),
5561        }
5562    }
5563
5564    fn applied_tip(&self) -> Result<LogAnchor, String> {
5565        match self {
5566            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5567            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5568            #[cfg(feature = "sql")]
5569            Self::Sql(state) => state
5570                .applied_tip()
5571                .map(|tip| LogAnchor::new(tip.applied_index(), tip.applied_hash()))
5572                .map_err(|error| error.to_string()),
5573            #[cfg(feature = "graph")]
5574            Self::Graph(state) => state.applied_tip().map_err(|error| error.to_string()),
5575            #[cfg(feature = "kv")]
5576            Self::Kv(state) => state.applied_tip().map_err(|error| error.to_string()),
5577        }
5578    }
5579
5580    fn configuration_state(&self) -> Result<Option<ConfigurationState>, String> {
5581        match self {
5582            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5583            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5584            #[cfg(feature = "sql")]
5585            Self::Sql(state) => state
5586                .configuration_state_value()
5587                .map(Some)
5588                .map_err(|error| error.to_string()),
5589            #[cfg(feature = "graph")]
5590            Self::Graph(_) => Ok(None),
5591            #[cfg(feature = "kv")]
5592            Self::Kv(_) => Ok(None),
5593        }
5594    }
5595
5596    fn apply_entry(&self, entry: &LogEntry) -> Result<Option<SqlCommandResult>, String> {
5597        match self {
5598            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5599            Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5600            #[cfg(feature = "sql")]
5601            Self::Sql(state) => state
5602                .apply_entry_with_result(entry)
5603                .map(|outcome| outcome.sql_result().cloned())
5604                .map_err(|error| error.to_string()),
5605            #[cfg(feature = "graph")]
5606            Self::Graph(state) => state
5607                .apply_entry(entry)
5608                .map(|_| None)
5609                .map_err(|error| error.to_string()),
5610            #[cfg(feature = "kv")]
5611            Self::Kv(state) => state
5612                .apply_entry(entry)
5613                .map(|_| None)
5614                .map_err(|error| error.to_string()),
5615        }
5616    }
5617}
5618
5619const READ_BARRIER_COALESCE_WINDOW: Duration = Duration::from_micros(50);
5620
5621struct ReadBarrierRounds {
5622    state: Mutex<ReadBarrierRoundsState>,
5623    collection_window: Duration,
5624}
5625
5626struct ReadBarrierRoundsState {
5627    tail: Option<Arc<ReadBarrierRound>>,
5628    next_generation: u64,
5629}
5630
5631struct ReadBarrierRound {
5632    #[cfg(test)]
5633    generation: u64,
5634    collection_deadline: Instant,
5635    predecessor: Mutex<Option<Arc<ReadBarrierRound>>>,
5636    phase: Mutex<ReadBarrierRoundPhase>,
5637    changed: Condvar,
5638}
5639
5640#[derive(Clone)]
5641enum ReadBarrierRoundPhase {
5642    Collecting,
5643    Running,
5644    Complete(Result<LogAnchor, NodeError>),
5645}
5646
5647struct ReadBarrierParticipant {
5648    round: Arc<ReadBarrierRound>,
5649    leader: bool,
5650}
5651
5652struct ReadBarrierPublication {
5653    round: Arc<ReadBarrierRound>,
5654    published: bool,
5655}
5656
5657impl ReadBarrierRounds {
5658    fn new(collection_window: Duration) -> Self {
5659        Self {
5660            state: Mutex::new(ReadBarrierRoundsState {
5661                tail: None,
5662                next_generation: 0,
5663            }),
5664            collection_window,
5665        }
5666    }
5667
5668    fn join(&self) -> Result<ReadBarrierParticipant, NodeError> {
5669        let mut state = self
5670            .state
5671            .lock()
5672            .map_err(|_| NodeError::Invariant("read barrier generation lock is poisoned".into()))?;
5673        if let Some(tail) = state.tail.as_ref() {
5674            let collecting = matches!(
5675                *tail.phase.lock().map_err(|_| {
5676                    NodeError::Invariant("read barrier round lock is poisoned".into())
5677                })?,
5678                ReadBarrierRoundPhase::Collecting
5679            );
5680            if collecting {
5681                return Ok(ReadBarrierParticipant {
5682                    round: Arc::clone(tail),
5683                    leader: false,
5684                });
5685            }
5686        }
5687
5688        let generation = state
5689            .next_generation
5690            .checked_add(1)
5691            .ok_or_else(|| NodeError::Invariant("read barrier generation is exhausted".into()))?;
5692        state.next_generation = generation;
5693        let collection_deadline = Instant::now()
5694            .checked_add(self.collection_window)
5695            .unwrap_or_else(Instant::now);
5696        let round = Arc::new(ReadBarrierRound {
5697            #[cfg(test)]
5698            generation,
5699            collection_deadline,
5700            predecessor: Mutex::new(state.tail.clone()),
5701            phase: Mutex::new(ReadBarrierRoundPhase::Collecting),
5702            changed: Condvar::new(),
5703        });
5704        state.tail = Some(Arc::clone(&round));
5705        Ok(ReadBarrierParticipant {
5706            round,
5707            leader: true,
5708        })
5709    }
5710
5711    fn cancel_waiters(&self) {
5712        let mut round = self.state.lock().ok().and_then(|state| state.tail.clone());
5713        while let Some(current) = round {
5714            if let Ok(_phase) = current.phase.lock() {
5715                current.changed.notify_all();
5716            }
5717            round = current
5718                .predecessor
5719                .lock()
5720                .ok()
5721                .and_then(|predecessor| predecessor.clone());
5722        }
5723    }
5724}
5725
5726impl ReadBarrierRound {
5727    fn wait_complete(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
5728        let mut phase = self
5729            .phase
5730            .lock()
5731            .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5732        loop {
5733            if matches!(*phase, ReadBarrierRoundPhase::Complete(_)) {
5734                return Ok(());
5735            }
5736            if cancelled.load(Ordering::Acquire) {
5737                return Err(NodeError::Unavailable(
5738                    "read barrier cancelled during shutdown".into(),
5739                ));
5740            }
5741            phase = self
5742                .changed
5743                .wait(phase)
5744                .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5745        }
5746    }
5747
5748    fn result(&self, cancelled: &AtomicBool) -> Result<LogAnchor, NodeError> {
5749        let mut phase = self
5750            .phase
5751            .lock()
5752            .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5753        loop {
5754            if let ReadBarrierRoundPhase::Complete(result) = &*phase {
5755                return result.clone();
5756            }
5757            if cancelled.load(Ordering::Acquire) {
5758                return Err(NodeError::Unavailable(
5759                    "read barrier cancelled during shutdown".into(),
5760                ));
5761            }
5762            phase = self
5763                .changed
5764                .wait(phase)
5765                .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5766        }
5767    }
5768
5769    fn complete(&self, result: Result<LogAnchor, NodeError>) {
5770        if let Ok(mut phase) = self.phase.lock() {
5771            if !matches!(*phase, ReadBarrierRoundPhase::Complete(_)) {
5772                *phase = ReadBarrierRoundPhase::Complete(result);
5773            }
5774            self.changed.notify_all();
5775        } else {
5776            self.changed.notify_all();
5777        }
5778    }
5779}
5780
5781impl ReadBarrierParticipant {
5782    #[cfg(test)]
5783    fn generation(&self) -> u64 {
5784        self.round.generation
5785    }
5786
5787    #[cfg(test)]
5788    fn is_leader(&self) -> bool {
5789        self.leader
5790    }
5791
5792    fn publication(&self) -> Option<ReadBarrierPublication> {
5793        self.leader.then(|| ReadBarrierPublication {
5794            round: Arc::clone(&self.round),
5795            published: false,
5796        })
5797    }
5798
5799    fn wait(&self, cancelled: &AtomicBool) -> Result<LogAnchor, NodeError> {
5800        self.round.result(cancelled)
5801    }
5802}
5803
5804impl ReadBarrierPublication {
5805    fn wait_turn(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
5806        let predecessor = self
5807            .round
5808            .predecessor
5809            .lock()
5810            .map_err(|_| NodeError::Invariant("read barrier predecessor lock is poisoned".into()))?
5811            .clone();
5812        if let Some(predecessor) = predecessor {
5813            predecessor.wait_complete(cancelled)?;
5814            self.round
5815                .predecessor
5816                .lock()
5817                .map_err(|_| {
5818                    NodeError::Invariant("read barrier predecessor lock is poisoned".into())
5819                })?
5820                .take();
5821        }
5822
5823        let mut phase = self
5824            .round
5825            .phase
5826            .lock()
5827            .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5828        loop {
5829            if cancelled.load(Ordering::Acquire) {
5830                return Err(NodeError::Unavailable(
5831                    "read barrier cancelled during shutdown".into(),
5832                ));
5833            }
5834            if !matches!(*phase, ReadBarrierRoundPhase::Collecting) {
5835                return Err(NodeError::Invariant(
5836                    "read barrier leader left the collecting phase early".into(),
5837                ));
5838            }
5839            let now = Instant::now();
5840            if now >= self.round.collection_deadline {
5841                return Ok(());
5842            }
5843            let wait = self
5844                .round
5845                .collection_deadline
5846                .saturating_duration_since(now);
5847            let (next, _) =
5848                self.round.changed.wait_timeout(phase, wait).map_err(|_| {
5849                    NodeError::Invariant("read barrier round lock is poisoned".into())
5850                })?;
5851            phase = next;
5852        }
5853    }
5854
5855    fn start(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
5856        let mut phase = self
5857            .round
5858            .phase
5859            .lock()
5860            .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5861        if cancelled.load(Ordering::Acquire) {
5862            return Err(NodeError::Unavailable(
5863                "read barrier cancelled during shutdown".into(),
5864            ));
5865        }
5866        if !matches!(*phase, ReadBarrierRoundPhase::Collecting) {
5867            return Err(NodeError::Invariant(
5868                "read barrier generation cannot start twice".into(),
5869            ));
5870        }
5871        *phase = ReadBarrierRoundPhase::Running;
5872        Ok(())
5873    }
5874
5875    fn publish(&mut self, result: Result<LogAnchor, NodeError>) {
5876        self.round.complete(result);
5877        self.published = true;
5878    }
5879}
5880
5881impl Drop for ReadBarrierPublication {
5882    fn drop(&mut self) {
5883        if !self.published {
5884            self.round.complete(Err(NodeError::Unavailable(
5885                "read barrier generation leader terminated".into(),
5886            )));
5887        }
5888    }
5889}
5890
5891#[cfg(all(test, feature = "kv"))]
5892type KvGroupCommitAfterExecuteHook = Arc<dyn Fn(&NodeRuntime) + Send + Sync>;
5893
5894pub struct NodeRuntime {
5895    config: NodeConfig,
5896    consensus: Arc<ThreeNodeConsensus>,
5897    log_store: FileLogStore,
5898    materializer: Mutex<Materializer>,
5899    commit: Mutex<()>,
5900    #[cfg(feature = "sql")]
5901    sql_group_commit: SqlGroupCommitQueue,
5902    #[cfg(feature = "kv")]
5903    kv_group_commit: KvGroupCommitQueue,
5904    read_barriers: ReadBarrierRounds,
5905    checkpointing: AtomicBool,
5906    operation_cancelled: AtomicBool,
5907    operation_cancelled_notify: tokio::sync::Notify,
5908    ready: AtomicBool,
5909    fatal: AtomicBool,
5910    fatal_reason: Mutex<Option<String>>,
5911    #[cfg(test)]
5912    materialized_tip_checks: AtomicUsize,
5913    #[cfg(all(test, feature = "sql"))]
5914    sql_group_commit_before_execute_hook: Option<Arc<dyn Fn() + Send + Sync>>,
5915    #[cfg(all(test, feature = "kv"))]
5916    kv_group_commit_before_execute_hook: Option<Arc<dyn Fn() + Send + Sync>>,
5917    #[cfg(all(test, feature = "kv"))]
5918    kv_group_commit_after_execute_hook: Option<KvGroupCommitAfterExecuteHook>,
5919    _data_root_lock: fs::File,
5920}
5921
5922#[cfg(feature = "sql")]
5923struct ExecutedPayload {
5924    response: WriteResponse,
5925    sql_result: Option<SqlCommandResult>,
5926}
5927
5928#[cfg(feature = "sql")]
5929trait SqlWritePhaseProfile {
5930    type Mark;
5931
5932    fn mark(&self) -> Self::Mark;
5933    fn commit_lock_acquired(&mut self);
5934    fn add_precheck_classification(&mut self, mark: Self::Mark);
5935    fn add_qwal_prepare(&mut self, mark: Self::Mark);
5936    fn add_consensus_propose(&mut self, mark: Self::Mark);
5937    fn add_local_qlog_mirror_append(&mut self, mark: Self::Mark);
5938    fn add_sql_materializer_apply(&mut self, mark: Self::Mark);
5939    fn record_success(&mut self, batch_member_count: usize);
5940}
5941
5942#[cfg(feature = "sql")]
5943struct DisabledSqlWritePhaseProfile;
5944
5945#[cfg(feature = "sql")]
5946impl SqlWritePhaseProfile for DisabledSqlWritePhaseProfile {
5947    type Mark = ();
5948
5949    #[inline]
5950    fn mark(&self) {}
5951
5952    #[inline]
5953    fn commit_lock_acquired(&mut self) {}
5954
5955    #[inline]
5956    fn add_precheck_classification(&mut self, (): ()) {}
5957
5958    #[inline]
5959    fn add_qwal_prepare(&mut self, (): ()) {}
5960
5961    #[inline]
5962    fn add_consensus_propose(&mut self, (): ()) {}
5963
5964    #[inline]
5965    fn add_local_qlog_mirror_append(&mut self, (): ()) {}
5966
5967    #[inline]
5968    fn add_sql_materializer_apply(&mut self, (): ()) {}
5969
5970    #[inline]
5971    fn record_success(&mut self, _batch_member_count: usize) {}
5972}
5973
5974#[cfg(feature = "sql")]
5975struct EnabledSqlWritePhaseProfile {
5976    observer: SqlWriteProfiler,
5977    service_started: Instant,
5978    commit_lock_wait: Duration,
5979    precheck_classification: Duration,
5980    qwal_prepare: Duration,
5981    consensus_propose: Duration,
5982    local_qlog_mirror_append: Duration,
5983    sql_materializer_apply: Duration,
5984}
5985
5986#[cfg(feature = "sql")]
5987impl EnabledSqlWritePhaseProfile {
5988    fn new(observer: SqlWriteProfiler) -> Self {
5989        Self {
5990            observer,
5991            service_started: Instant::now(),
5992            commit_lock_wait: Duration::ZERO,
5993            precheck_classification: Duration::ZERO,
5994            qwal_prepare: Duration::ZERO,
5995            consensus_propose: Duration::ZERO,
5996            local_qlog_mirror_append: Duration::ZERO,
5997            sql_materializer_apply: Duration::ZERO,
5998        }
5999    }
6000
6001    fn reset(&mut self) {
6002        self.service_started = Instant::now();
6003        self.commit_lock_wait = Duration::ZERO;
6004        self.precheck_classification = Duration::ZERO;
6005        self.qwal_prepare = Duration::ZERO;
6006        self.consensus_propose = Duration::ZERO;
6007        self.local_qlog_mirror_append = Duration::ZERO;
6008        self.sql_materializer_apply = Duration::ZERO;
6009    }
6010}
6011
6012#[cfg(feature = "sql")]
6013impl SqlWritePhaseProfile for EnabledSqlWritePhaseProfile {
6014    type Mark = Instant;
6015
6016    #[inline]
6017    fn mark(&self) -> Instant {
6018        Instant::now()
6019    }
6020
6021    fn commit_lock_acquired(&mut self) {
6022        self.commit_lock_wait = self.service_started.elapsed();
6023    }
6024
6025    fn add_precheck_classification(&mut self, mark: Instant) {
6026        self.precheck_classification += mark.elapsed();
6027    }
6028
6029    fn add_qwal_prepare(&mut self, mark: Instant) {
6030        self.qwal_prepare += mark.elapsed();
6031    }
6032
6033    fn add_consensus_propose(&mut self, mark: Instant) {
6034        self.consensus_propose += mark.elapsed();
6035    }
6036
6037    fn add_local_qlog_mirror_append(&mut self, mark: Instant) {
6038        self.local_qlog_mirror_append += mark.elapsed();
6039    }
6040
6041    fn add_sql_materializer_apply(&mut self, mark: Instant) {
6042        self.sql_materializer_apply += mark.elapsed();
6043    }
6044
6045    fn record_success(&mut self, batch_member_count: usize) {
6046        let total_service_us = duration_as_u64_micros(self.service_started.elapsed());
6047        let commit_lock_wait_us = duration_as_u64_micros(self.commit_lock_wait);
6048        let precheck_classification_us = duration_as_u64_micros(self.precheck_classification);
6049        let qwal_prepare_us = duration_as_u64_micros(self.qwal_prepare);
6050        let consensus_propose_us = duration_as_u64_micros(self.consensus_propose);
6051        let local_qlog_mirror_append_us = duration_as_u64_micros(self.local_qlog_mirror_append);
6052        let sql_materializer_apply_us = duration_as_u64_micros(self.sql_materializer_apply);
6053        let named_us = commit_lock_wait_us
6054            .saturating_add(precheck_classification_us)
6055            .saturating_add(qwal_prepare_us)
6056            .saturating_add(consensus_propose_us)
6057            .saturating_add(local_qlog_mirror_append_us)
6058            .saturating_add(sql_materializer_apply_us);
6059        self.observer.record(SqlWriteProfileSample {
6060            batch_member_count,
6061            commit_lock_wait_us,
6062            precheck_classification_us,
6063            qwal_prepare_us,
6064            consensus_propose_us,
6065            local_qlog_mirror_append_us,
6066            sql_materializer_apply_us,
6067            response_other_total_us: total_service_us.saturating_sub(named_us),
6068            total_service_us,
6069        });
6070        self.reset();
6071    }
6072}
6073
6074#[cfg(feature = "sql")]
6075fn duration_as_u64_micros(duration: Duration) -> u64 {
6076    u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
6077}
6078
6079#[cfg(feature = "sql")]
6080type CheckedSqlRequest = Result<Option<(RequestOutcome, SqlCommandResult)>, NodeError>;
6081
6082#[cfg(feature = "sql")]
6083#[derive(Clone, Debug, Eq, PartialEq)]
6084pub struct VerifiedSnapshotPublication {
6085    anchor: RecoveryAnchor,
6086}
6087
6088impl fmt::Debug for NodeRuntime {
6089    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6090        f.debug_struct("NodeRuntime")
6091            .field("config", &self.config)
6092            .field("ready", &self.ready.load(Ordering::Acquire))
6093            .field("fatal", &self.fatal.load(Ordering::Acquire))
6094            .finish_non_exhaustive()
6095    }
6096}
6097
6098impl NodeRuntime {
6099    pub fn open(
6100        config: NodeConfig,
6101        consensus: Arc<ThreeNodeConsensus>,
6102        peer_candidates: &[&dyn LogPeer],
6103    ) -> Result<Self, NodeError> {
6104        if config.ack_mode == AckMode::DrStrong {
6105            return Err(NodeError::UnsupportedAckMode(AckMode::DrStrong));
6106        }
6107        Materializer::ensure_profile_available(config.execution_profile())?;
6108        fs::create_dir_all(&config.data_dir)
6109            .map_err(|error| NodeError::Storage(error.to_string()))?;
6110        let lock_path = config.data_dir.join(".node.lock");
6111        let data_root_lock = fs::OpenOptions::new()
6112            .read(true)
6113            .write(true)
6114            .create(true)
6115            .truncate(false)
6116            .open(&lock_path)
6117            .map_err(|error| NodeError::Storage(error.to_string()))?;
6118        match data_root_lock.try_lock() {
6119            Ok(()) => {}
6120            Err(fs::TryLockError::WouldBlock) => {
6121                return Err(NodeError::DataRootLocked(config.data_dir.clone()));
6122            }
6123            Err(fs::TryLockError::Error(error)) => {
6124                return Err(NodeError::Storage(error.to_string()));
6125            }
6126        }
6127
6128        let log_store = FileLogStore::open_with_configuration(
6129            config.data_dir.join("consensus/log"),
6130            &config.cluster_id,
6131            config.epoch,
6132            config.log_initial_configuration.clone(),
6133        )
6134        .map_err(|error| NodeError::Storage(error.to_string()))?;
6135        let persisted_configuration = log_store
6136            .configuration_state()
6137            .map_err(|error| NodeError::Storage(error.to_string()))?;
6138        let recovery_anchor = log_store
6139            .logical_state()
6140            .map_err(|error| NodeError::Storage(error.to_string()))?
6141            .anchor;
6142        let materializer =
6143            Materializer::open(&config, &persisted_configuration, recovery_anchor.as_ref())?;
6144        reconcile_local_storage(&config, &log_store, &materializer)?;
6145        recover_peer_candidates(
6146            &config,
6147            consensus.as_ref(),
6148            &log_store,
6149            &materializer,
6150            peer_candidates,
6151        )?;
6152        recover_startup_decisions(&config, consensus.as_ref(), &log_store, &materializer)?;
6153
6154        Ok(Self {
6155            #[cfg(feature = "sql")]
6156            sql_group_commit: SqlGroupCommitQueue::new(config.sql_group_commit_queue_capacity),
6157            #[cfg(feature = "kv")]
6158            kv_group_commit: KvGroupCommitQueue::new(),
6159            config,
6160            consensus,
6161            log_store,
6162            materializer: Mutex::new(materializer),
6163            commit: Mutex::new(()),
6164            read_barriers: ReadBarrierRounds::new(READ_BARRIER_COALESCE_WINDOW),
6165            checkpointing: AtomicBool::new(false),
6166            operation_cancelled: AtomicBool::new(false),
6167            operation_cancelled_notify: tokio::sync::Notify::new(),
6168            ready: AtomicBool::new(true),
6169            fatal: AtomicBool::new(false),
6170            fatal_reason: Mutex::new(None),
6171            #[cfg(test)]
6172            materialized_tip_checks: AtomicUsize::new(0),
6173            #[cfg(all(test, feature = "sql"))]
6174            sql_group_commit_before_execute_hook: None,
6175            #[cfg(all(test, feature = "kv"))]
6176            kv_group_commit_before_execute_hook: None,
6177            #[cfg(all(test, feature = "kv"))]
6178            kv_group_commit_after_execute_hook: None,
6179            _data_root_lock: data_root_lock,
6180        })
6181    }
6182
6183    #[cfg(feature = "sql")]
6184    pub fn write(
6185        &self,
6186        request_id: &str,
6187        key: &str,
6188        value: &str,
6189    ) -> Result<WriteResponse, NodeError> {
6190        let payload = canonical_put(request_id, key, value)?;
6191        let _commit = self.lock_commit()?;
6192        self.execute_put_payload_locked(request_id, key, value, payload)
6193            .map(|outcome| outcome.response)
6194    }
6195
6196    #[cfg(feature = "graph")]
6197    pub fn mutate_graph(&self, command: GraphCommandV1) -> Result<GraphMutationOutcome, NodeError> {
6198        let payload = encode_replicated_graph_command(&command)
6199            .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6200        if payload.len() > MAX_COMMAND_BYTES {
6201            return Err(NodeError::InvalidRequest(format!(
6202                "command exceeds {MAX_COMMAND_BYTES} bytes"
6203            )));
6204        }
6205        let _commit = self.lock_commit()?;
6206        self.mutate_graph_payload_locked(&command, payload)
6207    }
6208
6209    /// Executes an ordered, non-atomic batch of graph mutations.
6210    ///
6211    /// Valid commands may share one QuePaxa log entry. Per-command conflicts remain isolated in
6212    /// the returned vector, whose order and length match `commands`. The whole vector is validated
6213    /// before the first write attempt, so an outer `Err` guarantees that nothing was attempted.
6214    #[cfg(feature = "graph")]
6215    #[cfg_attr(
6216        all(not(feature = "sql"), not(feature = "kv")),
6217        allow(unreachable_patterns)
6218    )]
6219    pub fn mutate_graph_batch(
6220        &self,
6221        commands: Vec<GraphCommandV1>,
6222    ) -> Result<Vec<Result<GraphMutationOutcome, NodeError>>, NodeError> {
6223        self.require_execution_profile(ExecutionProfile::Graph)?;
6224        validate_typed_batch_len(commands.len())?;
6225        let mut members = Vec::with_capacity(commands.len());
6226        for command in commands {
6227            let payload = encode_replicated_graph_command(&command)
6228                .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6229            validate_command_size(&payload)?;
6230            members.push(RuntimeBatchMember {
6231                #[cfg(feature = "sql")]
6232                request_id: command.request_id().to_owned(),
6233                payload,
6234                operation: QueuedOperation::Graph(command),
6235            });
6236        }
6237        Ok(self
6238            .execute_client_batch(members)
6239            .into_iter()
6240            .map(|result| {
6241                result.and_then(|response| match response {
6242                    ClientWriteResponse::Graph(outcome) => Ok(outcome),
6243                    _ => Err(NodeError::Invariant(
6244                        "graph batch returned a response for another profile".into(),
6245                    )),
6246                })
6247            })
6248            .collect())
6249    }
6250
6251    #[cfg(feature = "graph")]
6252    fn mutate_graph_payload_locked(
6253        &self,
6254        command: &GraphCommandV1,
6255        payload: Vec<u8>,
6256    ) -> Result<GraphMutationOutcome, NodeError> {
6257        self.ensure_ready()?;
6258        self.ensure_writes_active()?;
6259        if let Some(record) = self.check_graph_request(command.request_id(), &payload)? {
6260            return Ok(GraphMutationOutcome::from_record(record));
6261        }
6262        loop {
6263            let (last_index, last_hash) = self.ensure_materialized_tip()?;
6264            let slot = last_index.checked_add(1).ok_or_else(|| {
6265                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
6266            })?;
6267            let entry = self
6268                .consensus
6269                .propose_at_cancellable(
6270                    slot,
6271                    last_hash,
6272                    Command::new(CommandKind::Deterministic, payload.clone()),
6273                    &self.operation_cancelled,
6274                )
6275                .map_err(|error| self.map_consensus_error(error))?;
6276            self.persist_entry(&entry, slot, last_hash)?;
6277            if let Some(record) = self.check_graph_request(command.request_id(), &payload)? {
6278                return Ok(GraphMutationOutcome::from_record(record));
6279            }
6280            if entry.entry_type == EntryType::Command && entry.payload == payload {
6281                return Err(self.latch(NodeError::Invariant(
6282                    "committed graph request was not recorded by Ladybug".into(),
6283                )));
6284            }
6285        }
6286    }
6287
6288    #[cfg(feature = "graph")]
6289    pub fn get_graph_document(
6290        &self,
6291        id: &str,
6292        consistency: ReadConsistency,
6293    ) -> Result<GraphReadResponse, NodeError> {
6294        match consistency {
6295            ReadConsistency::Local => self.get_graph_document_local(id, None),
6296            ReadConsistency::AppliedIndex(required) => {
6297                self.get_graph_document_local(id, Some(required))
6298            }
6299            ReadConsistency::ReadBarrier => {
6300                let anchor = self.establish_read_barrier()?;
6301                self.validate_read_barrier_before_snapshot(anchor)?;
6302                let response = self.get_graph_document_local(id, Some(anchor.index()))?;
6303                self.validate_read_barrier_snapshot(
6304                    anchor,
6305                    LogAnchor::new(response.applied_index, response.hash),
6306                )?;
6307                Ok(response)
6308            }
6309        }
6310    }
6311
6312    #[cfg(feature = "graph")]
6313    pub fn query_graph(
6314        &self,
6315        statement: &str,
6316        parameters: &BTreeMap<String, GraphParameterValue>,
6317        consistency: ReadConsistency,
6318        max_rows: u32,
6319    ) -> Result<GraphQueryResult, NodeError> {
6320        if max_rows == 0 || max_rows > MAX_GRAPH_MAX_ROWS {
6321            return Err(NodeError::InvalidRequest(format!(
6322                "max_rows must be between 1 and {MAX_GRAPH_MAX_ROWS}"
6323            )));
6324        }
6325        match consistency {
6326            ReadConsistency::Local => self.query_graph_local(statement, parameters, None, max_rows),
6327            ReadConsistency::AppliedIndex(required) => {
6328                self.query_graph_local(statement, parameters, Some(required), max_rows)
6329            }
6330            ReadConsistency::ReadBarrier => {
6331                let anchor = self.establish_read_barrier()?;
6332                self.validate_read_barrier_before_snapshot(anchor)?;
6333                let result =
6334                    self.query_graph_local(statement, parameters, Some(anchor.index()), max_rows)?;
6335                self.validate_read_barrier_snapshot(
6336                    anchor,
6337                    LogAnchor::new(result.applied_index, result.hash),
6338                )?;
6339                Ok(result)
6340            }
6341        }
6342    }
6343
6344    #[cfg(feature = "kv")]
6345    pub fn mutate_kv(&self, command: KvCommandV1) -> Result<KvMutationOutcome, NodeError> {
6346        self.mutate_kv_batch(vec![command])?
6347            .into_iter()
6348            .next()
6349            .expect("one-member KV batch returns one result")
6350    }
6351
6352    /// Executes an ordered, non-atomic batch of KV mutations.
6353    ///
6354    /// Valid commands may share one QuePaxa log entry. Per-command conflicts remain isolated in
6355    /// the returned vector, whose order and length match `commands`. The whole vector is validated
6356    /// before the first write attempt, so an outer `Err` guarantees that nothing was attempted.
6357    #[cfg(feature = "kv")]
6358    #[cfg_attr(
6359        all(not(feature = "sql"), not(feature = "graph")),
6360        allow(unreachable_patterns)
6361    )]
6362    pub fn mutate_kv_batch(
6363        &self,
6364        commands: Vec<KvCommandV1>,
6365    ) -> Result<Vec<Result<KvMutationOutcome, NodeError>>, NodeError> {
6366        self.require_execution_profile(ExecutionProfile::Kv)?;
6367        validate_kv_batch_len(commands.len())?;
6368        let mut members = Vec::with_capacity(commands.len());
6369        for command in commands {
6370            let payload = encode_replicated_kv_command(&command)
6371                .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6372            validate_command_size(&payload)?;
6373            members.push(RuntimeBatchMember {
6374                #[cfg(feature = "sql")]
6375                request_id: command.request_id().to_owned(),
6376                payload,
6377                operation: QueuedOperation::Kv(command),
6378            });
6379        }
6380        Ok(self
6381            .execute_kv_group_commit(members)?
6382            .into_iter()
6383            .map(|result| {
6384                result.and_then(|response| match response {
6385                    ClientWriteResponse::Kv(outcome) => Ok(outcome),
6386                    _ => Err(NodeError::Invariant(
6387                        "KV batch returned a response for another profile".into(),
6388                    )),
6389                })
6390            })
6391            .collect())
6392    }
6393
6394    #[cfg(feature = "kv")]
6395    fn mutate_kv_payload_locked(
6396        &self,
6397        command: &KvCommandV1,
6398        payload: Vec<u8>,
6399    ) -> Result<KvMutationOutcome, NodeError> {
6400        self.ensure_ready()?;
6401        self.ensure_writes_active()?;
6402        if let Some(record) = self.check_kv_request(command.request_id(), &payload)? {
6403            return Ok(KvMutationOutcome::from_record(record));
6404        }
6405        loop {
6406            let (last_index, last_hash) = self.ensure_materialized_tip()?;
6407            let slot = last_index.checked_add(1).ok_or_else(|| {
6408                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
6409            })?;
6410            let entry = self
6411                .consensus
6412                .propose_at_cancellable(
6413                    slot,
6414                    last_hash,
6415                    Command::new(CommandKind::Deterministic, payload.clone()),
6416                    &self.operation_cancelled,
6417                )
6418                .map_err(|error| self.map_consensus_error(error))?;
6419            self.persist_entry(&entry, slot, last_hash)?;
6420            if let Some(record) = self.check_kv_request(command.request_id(), &payload)? {
6421                return Ok(KvMutationOutcome::from_record(record));
6422            }
6423            if entry.entry_type == EntryType::Command && entry.payload == payload {
6424                return Err(self.latch(NodeError::Invariant(
6425                    "committed KV request was not recorded by redb".into(),
6426                )));
6427            }
6428        }
6429    }
6430
6431    #[cfg(feature = "kv")]
6432    pub fn get_kv(
6433        &self,
6434        key: &[u8],
6435        consistency: ReadConsistency,
6436    ) -> Result<KvReadResponse, NodeError> {
6437        match consistency {
6438            ReadConsistency::Local => self.get_kv_local(key, None),
6439            ReadConsistency::AppliedIndex(required) => self.get_kv_local(key, Some(required)),
6440            ReadConsistency::ReadBarrier => {
6441                let anchor = self.establish_read_barrier()?;
6442                self.validate_read_barrier_before_snapshot(anchor)?;
6443                let response = self.get_kv_local(key, Some(anchor.index()))?;
6444                self.validate_read_barrier_snapshot(
6445                    anchor,
6446                    LogAnchor::new(response.applied_index, response.hash),
6447                )?;
6448                Ok(response)
6449            }
6450        }
6451    }
6452
6453    #[cfg(feature = "kv")]
6454    pub fn scan_kv_range(
6455        &self,
6456        start: &[u8],
6457        end: Option<&[u8]>,
6458        limit: usize,
6459        cursor: Option<&[u8]>,
6460        consistency: ReadConsistency,
6461    ) -> Result<KvScanResult, NodeError> {
6462        match consistency {
6463            ReadConsistency::Local => self.scan_kv_range_local(start, end, limit, cursor, None),
6464            ReadConsistency::AppliedIndex(required) => {
6465                self.scan_kv_range_local(start, end, limit, cursor, Some(required))
6466            }
6467            ReadConsistency::ReadBarrier => {
6468                let anchor = self.establish_read_barrier()?;
6469                self.validate_read_barrier_before_snapshot(anchor)?;
6470                let result =
6471                    self.scan_kv_range_local(start, end, limit, cursor, Some(anchor.index()))?;
6472                self.validate_read_barrier_snapshot(
6473                    anchor,
6474                    LogAnchor::new(result.tip().applied_index(), result.tip().applied_hash()),
6475                )?;
6476                Ok(result)
6477            }
6478        }
6479    }
6480
6481    #[cfg(feature = "kv")]
6482    pub fn scan_kv_prefix(
6483        &self,
6484        prefix: &[u8],
6485        limit: usize,
6486        cursor: Option<&[u8]>,
6487        consistency: ReadConsistency,
6488    ) -> Result<KvScanResult, NodeError> {
6489        match consistency {
6490            ReadConsistency::Local => self.scan_kv_prefix_local(prefix, limit, cursor, None),
6491            ReadConsistency::AppliedIndex(required) => {
6492                self.scan_kv_prefix_local(prefix, limit, cursor, Some(required))
6493            }
6494            ReadConsistency::ReadBarrier => {
6495                let anchor = self.establish_read_barrier()?;
6496                self.validate_read_barrier_before_snapshot(anchor)?;
6497                let result =
6498                    self.scan_kv_prefix_local(prefix, limit, cursor, Some(anchor.index()))?;
6499                self.validate_read_barrier_snapshot(
6500                    anchor,
6501                    LogAnchor::new(result.tip().applied_index(), result.tip().applied_hash()),
6502                )?;
6503                Ok(result)
6504            }
6505        }
6506    }
6507
6508    #[cfg(feature = "sql")]
6509    pub fn execute_sql(&self, command: SqlCommand) -> Result<WriteResponse, NodeError> {
6510        self.execute_sql_with_results(command)
6511            .map(|response| WriteResponse {
6512                applied_index: response.applied_index,
6513                hash: response.hash,
6514            })
6515    }
6516
6517    /// Executes an ordered, non-atomic batch of SQL commands.
6518    ///
6519    /// All successful, previously unseen members that fit the replicated command byte cap share
6520    /// one exact-base QWAL entry. Failed members are rolled back independently and later members
6521    /// continue. The aggregate canonical input is limited to [`MAX_COMMAND_BYTES`]. The whole
6522    /// vector is validated before the first write attempt, so an outer `Err` guarantees that
6523    /// nothing was attempted.
6524    #[cfg(feature = "sql")]
6525    pub fn execute_sql_batch(
6526        &self,
6527        commands: Vec<SqlCommand>,
6528    ) -> Result<Vec<Result<SqlExecuteResponse, NodeError>>, NodeError> {
6529        self.require_execution_profile(ExecutionProfile::Sqlite)?;
6530        validate_sql_batch_len(commands.len())?;
6531        let mut members = Vec::with_capacity(commands.len());
6532        let mut aggregate_encoded_bytes = 0_usize;
6533        for command in commands {
6534            validate_field(
6535                "request_id",
6536                &command.request_id,
6537                MAX_REQUEST_ID_BYTES,
6538                false,
6539            )?;
6540            let payload = encode_sql_command_with_index(&command)?;
6541            validate_command_size(&payload)?;
6542            aggregate_encoded_bytes = aggregate_encoded_bytes.saturating_add(payload.len());
6543            if aggregate_encoded_bytes > MAX_COMMAND_BYTES {
6544                return Err(NodeError::ResourceExhausted(format!(
6545                    "SQL write batch exceeds {MAX_COMMAND_BYTES} aggregate encoded bytes"
6546                )));
6547            }
6548            members.push(RuntimeBatchMember {
6549                request_id: command.request_id.clone(),
6550                payload,
6551                operation: QueuedOperation::Sql(command),
6552            });
6553        }
6554        let single_statement_batch = members.iter().all(|member| {
6555            matches!(
6556                &member.operation,
6557                QueuedOperation::Sql(command) if command.statements.len() == 1
6558            )
6559        });
6560        let results = if single_statement_batch {
6561            self.execute_sql_group_commit(members)?
6562        } else {
6563            self.execute_client_batch(members)
6564        };
6565        Ok(results
6566            .into_iter()
6567            .map(|result| {
6568                result.and_then(|response| match response {
6569                    ClientWriteResponse::Sql(response) => Ok(response),
6570                    _ => Err(NodeError::Invariant(
6571                        "SQL batch returned a response for another profile".into(),
6572                    )),
6573                })
6574            })
6575            .collect())
6576    }
6577
6578    #[cfg(feature = "sql")]
6579    fn execute_sql_with_results(
6580        &self,
6581        command: SqlCommand,
6582    ) -> Result<SqlExecuteResponse, NodeError> {
6583        self.execute_sql_batch(vec![command])?
6584            .into_iter()
6585            .next()
6586            .expect("one-member SQL batch returns one result")
6587    }
6588
6589    #[cfg(feature = "sql")]
6590    fn execute_sql_group_commit(&self, members: Vec<RuntimeBatchMember>) -> SqlGroupCommitResult {
6591        let (job, leader) = self
6592            .sql_group_commit
6593            .enqueue(members, &self.operation_cancelled)?;
6594        if leader {
6595            if let Some(observer) = self.config.sql_write_profiler.clone() {
6596                self.run_sql_group_commit_leader(&mut EnabledSqlWritePhaseProfile::new(observer));
6597            } else {
6598                self.run_sql_group_commit_leader(&mut DisabledSqlWritePhaseProfile);
6599            }
6600        }
6601        job.wait(&self.operation_cancelled)
6602    }
6603
6604    #[cfg(feature = "sql")]
6605    fn run_sql_group_commit_leader<P: SqlWritePhaseProfile>(&self, profile: &mut P) {
6606        let _commit = match self.lock_commit() {
6607            Ok(commit) => commit,
6608            Err(error) => {
6609                self.sql_group_commit.fail_pending(error);
6610                return;
6611            }
6612        };
6613        profile.commit_lock_acquired();
6614
6615        loop {
6616            if !self
6617                .sql_group_commit
6618                .collect_until_full_or_timeout(self.config.writer_batch_window())
6619            {
6620                break;
6621            }
6622            let Some(jobs) = self.sql_group_commit.drain_next_group() else {
6623                break;
6624            };
6625            if let Err(error) = self
6626                .ensure_ready()
6627                .and_then(|_| self.ensure_writes_active())
6628            {
6629                for job in &jobs {
6630                    job.publish(Err(error.clone()));
6631                }
6632                self.sql_group_commit.fail_pending(error);
6633                return;
6634            }
6635            let execution = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
6636                #[cfg(test)]
6637                if let Some(hook) = &self.sql_group_commit_before_execute_hook {
6638                    hook();
6639                }
6640                self.execute_sql_group_locked(&jobs, profile)
6641            }));
6642            let grouped_results = match execution {
6643                Ok(Ok(grouped_results)) => grouped_results,
6644                Ok(Err(error)) => {
6645                    let error = if matches!(error, NodeError::Unavailable(_)) {
6646                        error
6647                    } else {
6648                        self.latch(error)
6649                    };
6650                    for job in &jobs {
6651                        job.publish(Err(error.clone()));
6652                    }
6653                    self.sql_group_commit.fail_pending(error);
6654                    return;
6655                }
6656                Err(_) => {
6657                    let error =
6658                        self.latch(NodeError::Fatal("SQL group commit leader panicked".into()));
6659                    for job in &jobs {
6660                        job.publish(Err(error.clone()));
6661                    }
6662                    self.sql_group_commit.fail_pending(error);
6663                    return;
6664                }
6665            };
6666            for (job, results) in jobs.iter().zip(grouped_results) {
6667                job.publish(Ok(results));
6668            }
6669        }
6670    }
6671
6672    #[cfg(feature = "sql")]
6673    fn execute_sql_group_locked<P: SqlWritePhaseProfile>(
6674        &self,
6675        jobs: &[Arc<SqlGroupCommitJob>],
6676        profile: &mut P,
6677    ) -> Result<Vec<Vec<Result<ClientWriteResponse, NodeError>>>, NodeError> {
6678        if self.operation_cancelled.load(Ordering::Acquire) {
6679            return Err(NodeError::Unavailable(
6680                "SQL group commit cancelled during shutdown".into(),
6681            ));
6682        }
6683        let total_members = jobs.iter().map(|job| job.member_count).sum();
6684        if total_members == 0 || total_members > MAX_SQL_WRITE_BATCH_MEMBERS {
6685            return Err(NodeError::Invariant(format!(
6686                "SQL group commit drained {total_members} members outside 1..={MAX_SQL_WRITE_BATCH_MEMBERS}"
6687            )));
6688        }
6689        let mut members = Vec::with_capacity(total_members);
6690        for job in jobs {
6691            let job_members = job.take_members()?;
6692            if job_members.len() != job.member_count {
6693                return Err(NodeError::Invariant(
6694                    "SQL group commit job member count changed while queued".into(),
6695                ));
6696            }
6697            members.extend(job_members);
6698        }
6699        let results = self.execute_sql_client_batch_locked(&members, profile);
6700        if self.operation_cancelled.load(Ordering::Acquire) {
6701            return Err(NodeError::Unavailable(
6702                "SQL group commit cancelled during shutdown".into(),
6703            ));
6704        }
6705        if self.is_fatal() {
6706            return Err(NodeError::Fatal(
6707                self.fatal_reason()
6708                    .unwrap_or_else(|| "SQL group commit failed fatally".into()),
6709            ));
6710        }
6711        if results.len() != total_members {
6712            return Err(NodeError::Invariant(format!(
6713                "SQL group commit returned {} results for {total_members} members",
6714                results.len()
6715            )));
6716        }
6717        let mut results = results.into_iter();
6718        let grouped = jobs
6719            .iter()
6720            .map(|job| results.by_ref().take(job.member_count).collect::<Vec<_>>())
6721            .collect::<Vec<_>>();
6722        if results.next().is_some() || grouped.iter().map(Vec::len).sum::<usize>() != total_members
6723        {
6724            return Err(NodeError::Invariant(
6725                "SQL group commit result offsets were misaligned".into(),
6726            ));
6727        }
6728        Ok(grouped)
6729    }
6730
6731    #[cfg(feature = "sql")]
6732    fn execute_client_batch(
6733        &self,
6734        members: Vec<RuntimeBatchMember>,
6735    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
6736        if self.config.execution_profile != ExecutionProfile::Sqlite {
6737            return self.execute_profile_client_batch(members);
6738        }
6739        let is_single_statement_sql = members.iter().all(|member| {
6740            matches!(
6741                &member.operation,
6742                QueuedOperation::Sql(command) if command.statements.len() == 1
6743            )
6744        });
6745        if is_single_statement_sql {
6746            if let Some(observer) = self.config.sql_write_profiler.clone() {
6747                let mut profile = EnabledSqlWritePhaseProfile::new(observer);
6748                let _commit = match self.lock_commit() {
6749                    Ok(commit) => commit,
6750                    Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
6751                };
6752                profile.commit_lock_acquired();
6753                if let Err(error) = self
6754                    .ensure_ready()
6755                    .and_then(|_| self.ensure_writes_active())
6756                {
6757                    return members.into_iter().map(|_| Err(error.clone())).collect();
6758                }
6759                return self.execute_sql_client_batch_locked(&members, &mut profile);
6760            }
6761        }
6762        let _commit = match self.lock_commit() {
6763            Ok(commit) => commit,
6764            Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
6765        };
6766        if let Err(error) = self
6767            .ensure_ready()
6768            .and_then(|_| self.ensure_writes_active())
6769        {
6770            return members.into_iter().map(|_| Err(error.clone())).collect();
6771        }
6772
6773        if is_single_statement_sql {
6774            return self
6775                .execute_sql_client_batch_locked(&members, &mut DisabledSqlWritePhaseProfile);
6776        }
6777
6778        members
6779            .iter()
6780            .map(|member| self.execute_single_member_locked(member))
6781            .collect()
6782    }
6783
6784    #[cfg(feature = "sql")]
6785    fn execute_sql_client_batch_locked<P: SqlWritePhaseProfile>(
6786        &self,
6787        members: &[RuntimeBatchMember],
6788        profile: &mut P,
6789    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
6790        let classification_mark = profile.mark();
6791        let mut results = vec![None; members.len()];
6792        let mut pending = Vec::new();
6793        let mut canonical_by_request: HashMap<String, Vec<usize>> = HashMap::new();
6794        let mut lookup_indices = Vec::with_capacity(members.len());
6795        let mut aliases = vec![None; members.len()];
6796        let mut blocked_by = vec![None; members.len()];
6797
6798        for (index, member) in members.iter().enumerate() {
6799            let QueuedOperation::Sql(command) = &member.operation else {
6800                unreachable!("SQL batch members were validated by the caller");
6801            };
6802            let canonicals = canonical_by_request
6803                .entry(command.request_id.clone())
6804                .or_default();
6805            if let Some(canonical) = canonicals
6806                .iter()
6807                .copied()
6808                .find(|canonical| members[*canonical].payload == member.payload)
6809            {
6810                aliases[index] = Some(canonical);
6811                continue;
6812            }
6813            blocked_by[index] = canonicals.last().copied();
6814            canonicals.push(index);
6815            lookup_indices.push(index);
6816        }
6817
6818        let preflight = self.check_sql_members_bulk(members, &lookup_indices);
6819        let preflight = match preflight {
6820            Ok(preflight) => preflight,
6821            Err(error) => {
6822                profile.add_precheck_classification(classification_mark);
6823                return members.iter().map(|_| Err(error.clone())).collect();
6824            }
6825        };
6826        for (index, lookup) in preflight {
6827            match lookup {
6828                Ok(Some((outcome, sql_result))) => {
6829                    results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
6830                        write_response(outcome),
6831                        Some(sql_result),
6832                    ))));
6833                }
6834                Ok(None) => pending.push(index),
6835                Err(error) => results[index] = Some(Err(error)),
6836            }
6837        }
6838        profile.add_precheck_classification(classification_mark);
6839
6840        while !pending.is_empty() {
6841            let eligible = pending
6842                .iter()
6843                .copied()
6844                .filter(|index| {
6845                    blocked_by[*index].is_none_or(|predecessor| results[predecessor].is_some())
6846                })
6847                .take(MAX_SQL_WRITE_BATCH_MEMBERS)
6848                .collect::<Vec<_>>();
6849            if eligible.is_empty() {
6850                let error = self.latch(NodeError::Invariant(
6851                    "SQL writer batch has an unresolved duplicate dependency".into(),
6852                ));
6853                for index in pending.drain(..) {
6854                    results[index] = Some(Err(error.clone()));
6855                }
6856                break;
6857            }
6858
6859            let (last_index, last_hash) = match self.ensure_materialized_tip() {
6860                Ok(tip) => tip,
6861                Err(error) => {
6862                    for index in pending.drain(..) {
6863                        results[index] = Some(Err(error.clone()));
6864                    }
6865                    break;
6866                }
6867            };
6868            let mut attempt_count = eligible.len();
6869            let (proposal_payload, prepared_results) = loop {
6870                let attempted = &eligible[..attempt_count];
6871                let batch_members = attempted
6872                    .iter()
6873                    .map(|index| {
6874                        let QueuedOperation::Sql(command) = &members[*index].operation else {
6875                            unreachable!("SQL batch members were validated above");
6876                        };
6877                        SqlBatchMember {
6878                            command,
6879                            request_payload: &members[*index].payload,
6880                        }
6881                    })
6882                    .collect::<Vec<_>>();
6883                let preparation_mark = profile.mark();
6884                let preparation = self.lock_sqlite().and_then(|sqlite| {
6885                    sqlite
6886                        .prepare_sql_batch_effect(&batch_members, last_index, last_hash)
6887                        .map_err(|error| self.map_sqlite_error(error))
6888                });
6889                profile.add_qwal_prepare(preparation_mark);
6890                let preparation = match preparation {
6891                    Ok(preparation) => preparation,
6892                    Err(NodeError::ResourceExhausted(message)) if attempt_count > 1 => {
6893                        attempt_count = attempt_count.div_ceil(2);
6894                        let _ = message;
6895                        continue;
6896                    }
6897                    Err(NodeError::ResourceExhausted(message)) => {
6898                        results[attempted[0]] = Some(Err(NodeError::ResourceExhausted(message)));
6899                        break (None, Vec::new());
6900                    }
6901                    Err(error) => {
6902                        for index in pending.drain(..) {
6903                            results[index] = Some(Err(error.clone()));
6904                        }
6905                        break (None, Vec::new());
6906                    }
6907                };
6908                if preparation.results.len() != attempted.len() {
6909                    let error = self.latch(NodeError::Invariant(
6910                        "SQLite batch preparation returned a misaligned result vector".into(),
6911                    ));
6912                    for index in pending.drain(..) {
6913                        results[index] = Some(Err(error.clone()));
6914                    }
6915                    break (None, Vec::new());
6916                }
6917
6918                let mut proposed = Vec::new();
6919                for (index, member_result) in attempted.iter().copied().zip(preparation.results) {
6920                    match member_result {
6921                        Ok(result) => proposed.push((index, result)),
6922                        Err(error) => {
6923                            results[index] = Some(Err(self.map_sql_batch_member_error(error)))
6924                        }
6925                    }
6926                }
6927                match preparation.effect {
6928                    Some(_) if proposed.is_empty() => {
6929                        let error = self.latch(NodeError::Invariant(
6930                            "SQLite prepared an effect without a successful SQL member".into(),
6931                        ));
6932                        for index in pending.drain(..) {
6933                            results[index] = Some(Err(error.clone()));
6934                        }
6935                        break (None, Vec::new());
6936                    }
6937                    Some(payload) if !payload.starts_with(QWAL_V3_MAGIC) => {
6938                        let error = self.latch(NodeError::Invariant(
6939                            "SQLite materializer prepared a non-QWAL v3 SQL batch".into(),
6940                        ));
6941                        for index in pending.drain(..) {
6942                            results[index] = Some(Err(error.clone()));
6943                        }
6944                        break (None, Vec::new());
6945                    }
6946                    Some(payload) if payload.len() <= MAX_COMMAND_BYTES => {
6947                        break (Some(payload), proposed)
6948                    }
6949                    Some(_) if attempt_count > 1 => {
6950                        for index in attempted {
6951                            results[*index] = None;
6952                        }
6953                        attempt_count = attempt_count.div_ceil(2);
6954                    }
6955                    Some(_) => {
6956                        results[attempted[0]] = Some(Err(NodeError::ResourceExhausted(format!(
6957                            "SQL effect exceeds {MAX_COMMAND_BYTES} bytes"
6958                        ))));
6959                        break (None, Vec::new());
6960                    }
6961                    None if proposed.is_empty() => break (None, Vec::new()),
6962                    None => {
6963                        let error = self.latch(NodeError::Invariant(
6964                            "SQLite omitted the effect for successful SQL members".into(),
6965                        ));
6966                        for index in pending.drain(..) {
6967                            results[index] = Some(Err(error.clone()));
6968                        }
6969                        break (None, Vec::new());
6970                    }
6971                }
6972            };
6973
6974            pending.retain(|index| results[*index].is_none());
6975            let Some(proposal_payload) = proposal_payload else {
6976                continue;
6977            };
6978            let slot = match last_index.checked_add(1) {
6979                Some(slot) => slot,
6980                None => {
6981                    let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
6982                    for index in pending.drain(..) {
6983                        results[index] = Some(Err(error.clone()));
6984                    }
6985                    break;
6986                }
6987            };
6988            let consensus_mark = profile.mark();
6989            let entry = self.consensus.propose_at_cancellable(
6990                slot,
6991                last_hash,
6992                Command::new(CommandKind::Deterministic, proposal_payload.clone()),
6993                &self.operation_cancelled,
6994            );
6995            profile.add_consensus_propose(consensus_mark);
6996            let entry = match entry {
6997                Ok(entry) => entry,
6998                Err(error) => {
6999                    let error = self.map_consensus_error(error);
7000                    for index in pending.drain(..) {
7001                        results[index] = Some(Err(error.clone()));
7002                    }
7003                    break;
7004                }
7005            };
7006            if let Err(error) = self.persist_sql_entry_profiled(&entry, slot, last_hash, profile) {
7007                for index in pending.drain(..) {
7008                    results[index] = Some(Err(error.clone()));
7009                }
7010                break;
7011            }
7012
7013            let exact_winner =
7014                entry.entry_type == EntryType::Command && entry.payload == proposal_payload;
7015            let exact_winner_member_count = exact_winner.then_some(prepared_results.len());
7016            if exact_winner {
7017                for (index, sql_result) in prepared_results {
7018                    results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7019                        WriteResponse {
7020                            applied_index: entry.index,
7021                            hash: entry.hash,
7022                        },
7023                        Some(sql_result),
7024                    ))));
7025                }
7026                pending.retain(|index| results[*index].is_none());
7027            }
7028
7029            let current_pending = std::mem::take(&mut pending);
7030            let classification_mark = profile.mark();
7031            let post_commit = self.check_sql_members_bulk(members, &current_pending);
7032            let post_commit = match post_commit {
7033                Ok(post_commit) => post_commit,
7034                Err(error) => {
7035                    for index in current_pending {
7036                        results[index] = Some(Err(error.clone()));
7037                    }
7038                    profile.add_precheck_classification(classification_mark);
7039                    if let Some(batch_member_count) = exact_winner_member_count {
7040                        profile.record_success(batch_member_count);
7041                    }
7042                    break;
7043                }
7044            };
7045            for (index, lookup) in post_commit {
7046                match lookup {
7047                    Ok(Some((outcome, sql_result))) => {
7048                        results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7049                            write_response(outcome),
7050                            Some(sql_result),
7051                        ))));
7052                    }
7053                    Ok(None) => pending.push(index),
7054                    Err(error) => results[index] = Some(Err(error)),
7055                }
7056            }
7057            profile.add_precheck_classification(classification_mark);
7058            if let Some(batch_member_count) = exact_winner_member_count {
7059                profile.record_success(batch_member_count);
7060            }
7061        }
7062
7063        for (index, canonical) in aliases.into_iter().enumerate() {
7064            if let Some(canonical) = canonical {
7065                results[index] = results[canonical].clone();
7066            }
7067        }
7068        results
7069            .into_iter()
7070            .map(|result| {
7071                result.unwrap_or_else(|| {
7072                    Err(self.latch(NodeError::Invariant(
7073                        "SQL writer batch omitted a request result".into(),
7074                    )))
7075                })
7076            })
7077            .collect()
7078    }
7079
7080    #[cfg(feature = "sql")]
7081    fn check_sql_members_bulk(
7082        &self,
7083        members: &[RuntimeBatchMember],
7084        indices: &[usize],
7085    ) -> Result<Vec<(usize, CheckedSqlRequest)>, NodeError> {
7086        if indices.is_empty() {
7087            return Ok(Vec::new());
7088        }
7089        let mut rounds = Vec::<Vec<usize>>::new();
7090        let mut occurrence_by_request = HashMap::<String, usize>::new();
7091        for index in indices {
7092            let QueuedOperation::Sql(command) = &members[*index].operation else {
7093                return Err(self.latch(NodeError::Invariant(
7094                    "non-SQL member reached SQL receipt precheck".into(),
7095                )));
7096            };
7097            let round = occurrence_by_request
7098                .entry(command.request_id.clone())
7099                .or_default();
7100            if rounds.len() == *round {
7101                rounds.push(Vec::new());
7102            }
7103            rounds[*round].push(*index);
7104            *round += 1;
7105        }
7106
7107        let sqlite = self.lock_sqlite()?;
7108        let mut checked = Vec::with_capacity(indices.len());
7109        for round in rounds {
7110            let requests = round
7111                .iter()
7112                .map(|index| {
7113                    let QueuedOperation::Sql(command) = &members[*index].operation else {
7114                        unreachable!("SQL receipt round contains only SQL members");
7115                    };
7116                    (
7117                        command.request_id.as_str(),
7118                        members[*index].payload.as_slice(),
7119                    )
7120                })
7121                .collect::<Vec<_>>();
7122            let lookups = sqlite
7123                .check_sql_requests(&requests)
7124                .map_err(|error| self.map_sqlite_error(error))?;
7125            if lookups.len() != round.len() {
7126                return Err(self.latch(NodeError::Invariant(
7127                    "SQLite bulk receipt precheck returned a misaligned result vector".into(),
7128                )));
7129            }
7130            for (index, lookup) in round.into_iter().zip(lookups) {
7131                checked.push((
7132                    index,
7133                    match lookup {
7134                        Ok(Some((outcome, Some(sql_result)))) => Ok(Some((outcome, sql_result))),
7135                        Ok(Some((_, None))) => Err(self.latch(NodeError::Invariant(
7136                            "stored SQL receipt omitted its command result".into(),
7137                        ))),
7138                        Ok(None) => Ok(None),
7139                        Err(error) => Err(self.map_sql_batch_member_error(error)),
7140                    },
7141                ));
7142            }
7143        }
7144        Ok(checked)
7145    }
7146
7147    #[cfg(feature = "kv")]
7148    fn execute_kv_group_commit(&self, members: Vec<RuntimeBatchMember>) -> KvGroupCommitResult {
7149        let (job, leader) = self
7150            .kv_group_commit
7151            .enqueue(members, &self.operation_cancelled)?;
7152        if leader {
7153            self.run_kv_group_commit_leader();
7154        }
7155        job.wait(&self.operation_cancelled)
7156    }
7157
7158    #[cfg(feature = "kv")]
7159    fn run_kv_group_commit_leader(&self) {
7160        let _commit = match self.lock_commit() {
7161            Ok(commit) => commit,
7162            Err(error) => {
7163                self.kv_group_commit.fail_pending(error);
7164                return;
7165            }
7166        };
7167
7168        loop {
7169            if !self
7170                .kv_group_commit
7171                .collect_until_full_or_timeout(self.config.writer_batch_window())
7172            {
7173                break;
7174            }
7175            let Some(jobs) = self.kv_group_commit.drain_next_group() else {
7176                break;
7177            };
7178            if let Err(error) = self
7179                .ensure_ready()
7180                .and_then(|_| self.ensure_writes_active())
7181            {
7182                for job in &jobs {
7183                    job.publish(Err(error.clone()));
7184                }
7185                self.kv_group_commit.fail_pending(error);
7186                return;
7187            }
7188            let execution = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7189                #[cfg(test)]
7190                if let Some(hook) = &self.kv_group_commit_before_execute_hook {
7191                    hook();
7192                }
7193                self.execute_kv_group_locked(&jobs)
7194            }));
7195            let grouped_results = match execution {
7196                Ok(Ok(grouped_results)) => grouped_results,
7197                Ok(Err(error)) => {
7198                    let error = if matches!(error, NodeError::Unavailable(_)) {
7199                        error
7200                    } else {
7201                        self.latch(error)
7202                    };
7203                    for job in &jobs {
7204                        job.publish(Err(error.clone()));
7205                    }
7206                    self.kv_group_commit.fail_pending(error);
7207                    return;
7208                }
7209                Err(_) => {
7210                    let error =
7211                        self.latch(NodeError::Fatal("KV group commit leader panicked".into()));
7212                    for job in &jobs {
7213                        job.publish(Err(error.clone()));
7214                    }
7215                    self.kv_group_commit.fail_pending(error);
7216                    return;
7217                }
7218            };
7219            for (job, results) in jobs.iter().zip(grouped_results) {
7220                job.publish(Ok(results));
7221            }
7222        }
7223    }
7224
7225    #[cfg(feature = "kv")]
7226    fn execute_kv_group_locked(
7227        &self,
7228        jobs: &[Arc<KvGroupCommitJob>],
7229    ) -> Result<Vec<Vec<Result<ClientWriteResponse, NodeError>>>, NodeError> {
7230        if self.operation_cancelled.load(Ordering::Acquire) {
7231            return Err(NodeError::Unavailable(
7232                "KV group commit cancelled during shutdown".into(),
7233            ));
7234        }
7235        let total_members = jobs.iter().map(|job| job.member_count).sum();
7236        if total_members == 0 || total_members > MAX_KV_GROUP_COMMIT_MEMBERS {
7237            return Err(NodeError::Invariant(format!(
7238                "KV group commit drained {total_members} members outside 1..={MAX_KV_GROUP_COMMIT_MEMBERS}"
7239            )));
7240        }
7241        let mut members = Vec::with_capacity(total_members);
7242        for job in jobs {
7243            let job_members = job.take_members()?;
7244            if job_members.len() != job.member_count {
7245                return Err(NodeError::Invariant(
7246                    "KV group commit job member count changed while queued".into(),
7247                ));
7248            }
7249            members.extend(job_members);
7250        }
7251        let results = self.execute_kv_client_batch_locked(&members);
7252        #[cfg(test)]
7253        if let Some(hook) = &self.kv_group_commit_after_execute_hook {
7254            hook(self);
7255        }
7256        if results.len() != total_members {
7257            let error = self.latch(NodeError::Invariant(format!(
7258                "KV group commit returned {} results for {total_members} members",
7259                results.len()
7260            )));
7261            return Ok(jobs
7262                .iter()
7263                .map(|job| vec![Err(error.clone()); job.member_count])
7264                .collect());
7265        }
7266        let mut results = results.into_iter();
7267        let grouped = jobs
7268            .iter()
7269            .map(|job| results.by_ref().take(job.member_count).collect::<Vec<_>>())
7270            .collect::<Vec<_>>();
7271        debug_assert!(results.next().is_none());
7272        debug_assert_eq!(grouped.iter().map(Vec::len).sum::<usize>(), total_members);
7273        Ok(grouped)
7274    }
7275
7276    #[cfg(not(feature = "sql"))]
7277    fn execute_client_batch(
7278        &self,
7279        members: Vec<RuntimeBatchMember>,
7280    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7281        self.execute_profile_client_batch(members)
7282    }
7283
7284    fn execute_profile_client_batch(
7285        &self,
7286        members: Vec<RuntimeBatchMember>,
7287    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7288        #[cfg(feature = "graph")]
7289        if self.config.execution_profile == ExecutionProfile::Graph {
7290            return self.execute_graph_client_batch(members);
7291        }
7292        #[cfg(feature = "kv")]
7293        if self.config.execution_profile == ExecutionProfile::Kv {
7294            return self.execute_kv_client_batch(members);
7295        }
7296        let _commit = match self.lock_commit() {
7297            Ok(commit) => commit,
7298            Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7299        };
7300        if let Err(error) = self
7301            .ensure_ready()
7302            .and_then(|_| self.ensure_writes_active())
7303        {
7304            return members.into_iter().map(|_| Err(error.clone())).collect();
7305        }
7306        members
7307            .iter()
7308            .map(|member| self.execute_profile_member_locked(member))
7309            .collect()
7310    }
7311
7312    #[cfg(feature = "graph")]
7313    #[cfg_attr(
7314        all(not(feature = "sql"), not(feature = "kv")),
7315        allow(irrefutable_let_patterns, unreachable_patterns)
7316    )]
7317    fn execute_graph_client_batch(
7318        &self,
7319        members: Vec<RuntimeBatchMember>,
7320    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7321        let _commit = match self.lock_commit() {
7322            Ok(commit) => commit,
7323            Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7324        };
7325        if let Err(error) = self
7326            .ensure_ready()
7327            .and_then(|_| self.ensure_writes_active())
7328        {
7329            return members.into_iter().map(|_| Err(error.clone())).collect();
7330        }
7331
7332        let mut results = vec![None; members.len()];
7333        let mut pending = Vec::new();
7334        let mut canonical_by_request = HashMap::new();
7335        let mut aliases = vec![None; members.len()];
7336        for (index, member) in members.iter().enumerate() {
7337            let QueuedOperation::Graph(command) = &member.operation else {
7338                results[index] = Some(Err(NodeError::ExecutionProfileMismatch {
7339                    expected: ExecutionProfile::Graph,
7340                    actual: ExecutionProfile::Sqlite,
7341                }));
7342                continue;
7343            };
7344            match self.check_graph_request(command.request_id(), &member.payload) {
7345                Ok(Some(record)) => {
7346                    results[index] = Some(Ok(ClientWriteResponse::Graph(
7347                        GraphMutationOutcome::from_record(record),
7348                    )));
7349                }
7350                Ok(None) => match classify_pending_request(
7351                    &mut canonical_by_request,
7352                    &members,
7353                    index,
7354                    command.request_id(),
7355                ) {
7356                    Ok(None) => pending.push(index),
7357                    Ok(Some(canonical)) => aliases[index] = Some(canonical),
7358                    Err(error) => results[index] = Some(Err(error)),
7359                },
7360                Err(error) => results[index] = Some(Err(error)),
7361            }
7362        }
7363
7364        while !pending.is_empty() {
7365            if pending.len() == 1 {
7366                let index = pending[0];
7367                results[index] = Some(self.execute_profile_member_locked(&members[index]));
7368                break;
7369            }
7370            let commands = pending
7371                .iter()
7372                .map(|index| match &members[*index].operation {
7373                    QueuedOperation::Graph(command) => command.clone(),
7374                    _ => unreachable!("graph pending members were validated above"),
7375                })
7376                .collect::<Vec<_>>();
7377            let full_payload = match encode_replicated_graph_batch(&commands) {
7378                Ok(payload) => payload,
7379                Err(error) => {
7380                    let error = NodeError::InvalidRequest(error.to_string());
7381                    for index in pending.drain(..) {
7382                        results[index] = Some(Err(error.clone()));
7383                    }
7384                    break;
7385                }
7386            };
7387            let (proposal_count, proposal_payload) = if full_payload.len() <= MAX_COMMAND_BYTES {
7388                (commands.len(), full_payload)
7389            } else {
7390                let mut prefix = None;
7391                for count in (2..commands.len()).rev() {
7392                    let payload = encode_replicated_graph_batch(&commands[..count])
7393                        .expect("the validated graph batch prefix remains valid");
7394                    if payload.len() <= MAX_COMMAND_BYTES {
7395                        prefix = Some((count, payload));
7396                        break;
7397                    }
7398                }
7399                let Some(prefix) = prefix else {
7400                    let index = pending.remove(0);
7401                    results[index] = Some(self.execute_profile_member_locked(&members[index]));
7402                    continue;
7403                };
7404                prefix
7405            };
7406            let proposed_indices = pending[..proposal_count].to_vec();
7407            let (last_index, last_hash) = match self.ensure_materialized_tip() {
7408                Ok(tip) => tip,
7409                Err(error) => {
7410                    for index in pending.drain(..) {
7411                        results[index] = Some(Err(error.clone()));
7412                    }
7413                    break;
7414                }
7415            };
7416            let slot = match last_index.checked_add(1) {
7417                Some(slot) => slot,
7418                None => {
7419                    let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7420                    for index in pending.drain(..) {
7421                        results[index] = Some(Err(error.clone()));
7422                    }
7423                    break;
7424                }
7425            };
7426            let entry = match self.consensus.propose_at_cancellable(
7427                slot,
7428                last_hash,
7429                Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7430                &self.operation_cancelled,
7431            ) {
7432                Ok(entry) => entry,
7433                Err(error) => {
7434                    let error = self.map_consensus_error(error);
7435                    for index in pending.drain(..) {
7436                        results[index] = Some(Err(error.clone()));
7437                    }
7438                    break;
7439                }
7440            };
7441            if let Err(error) = self.persist_entry(&entry, slot, last_hash) {
7442                for index in pending.drain(..) {
7443                    results[index] = Some(Err(error.clone()));
7444                }
7445                break;
7446            }
7447
7448            let mut remaining = Vec::new();
7449            for index in pending.drain(..) {
7450                let member = &members[index];
7451                let QueuedOperation::Graph(command) = &member.operation else {
7452                    unreachable!("graph pending members were validated above");
7453                };
7454                match self.check_graph_request(command.request_id(), &member.payload) {
7455                    Ok(Some(record)) => {
7456                        results[index] = Some(Ok(ClientWriteResponse::Graph(
7457                            GraphMutationOutcome::from_record(record),
7458                        )));
7459                    }
7460                    Ok(None) => remaining.push(index),
7461                    Err(error) => results[index] = Some(Err(error)),
7462                }
7463            }
7464            if entry.entry_type == EntryType::Command
7465                && entry.payload == proposal_payload
7466                && remaining
7467                    .iter()
7468                    .any(|index| proposed_indices.contains(index))
7469            {
7470                let error = self.latch(NodeError::Invariant(
7471                    "committed graph batch did not record every request".into(),
7472                ));
7473                for index in remaining.drain(..) {
7474                    results[index] = Some(Err(error.clone()));
7475                }
7476            }
7477            pending = remaining;
7478        }
7479
7480        for (index, canonical) in aliases.into_iter().enumerate() {
7481            if let Some(canonical) = canonical {
7482                results[index] = results[canonical].clone();
7483            }
7484        }
7485
7486        results
7487            .into_iter()
7488            .map(|result| {
7489                result.unwrap_or_else(|| {
7490                    Err(self.latch(NodeError::Invariant(
7491                        "graph writer batch omitted a request result".into(),
7492                    )))
7493                })
7494            })
7495            .collect()
7496    }
7497
7498    #[cfg(feature = "kv")]
7499    #[cfg_attr(
7500        all(not(feature = "sql"), not(feature = "graph")),
7501        allow(irrefutable_let_patterns, unreachable_patterns)
7502    )]
7503    fn execute_kv_client_batch(
7504        &self,
7505        members: Vec<RuntimeBatchMember>,
7506    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7507        let _commit = match self.lock_commit() {
7508            Ok(commit) => commit,
7509            Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7510        };
7511        if let Err(error) = self
7512            .ensure_ready()
7513            .and_then(|_| self.ensure_writes_active())
7514        {
7515            return members.into_iter().map(|_| Err(error.clone())).collect();
7516        }
7517        self.execute_kv_client_batch_locked(&members)
7518    }
7519
7520    #[cfg(feature = "kv")]
7521    #[cfg_attr(
7522        all(not(feature = "sql"), not(feature = "graph")),
7523        allow(irrefutable_let_patterns, unreachable_patterns)
7524    )]
7525    fn execute_kv_client_batch_locked(
7526        &self,
7527        members: &[RuntimeBatchMember],
7528    ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7529        let mut results = vec![None; members.len()];
7530        let mut pending = Vec::new();
7531        let mut canonical_by_request = HashMap::new();
7532        let mut aliases = vec![None; members.len()];
7533        let mut lookup_indices = Vec::with_capacity(members.len());
7534        for (index, member) in members.iter().enumerate() {
7535            let QueuedOperation::Kv(command) = &member.operation else {
7536                results[index] = Some(Err(NodeError::ExecutionProfileMismatch {
7537                    expected: ExecutionProfile::Kv,
7538                    actual: ExecutionProfile::Sqlite,
7539                }));
7540                continue;
7541            };
7542            match classify_pending_request(
7543                &mut canonical_by_request,
7544                members,
7545                index,
7546                command.request_id(),
7547            ) {
7548                Ok(None) => lookup_indices.push(index),
7549                Ok(Some(canonical)) => aliases[index] = Some(canonical),
7550                Err(error) => results[index] = Some(Err(error)),
7551            }
7552        }
7553        let preflight = match self.check_kv_members_bulk(members, &lookup_indices) {
7554            Ok(preflight) => preflight,
7555            Err(error) => return members.iter().map(|_| Err(error.clone())).collect(),
7556        };
7557        for (index, lookup) in preflight {
7558            match lookup {
7559                Ok(Some(record)) => {
7560                    results[index] = Some(Ok(ClientWriteResponse::Kv(
7561                        KvMutationOutcome::from_record(record),
7562                    )));
7563                }
7564                Ok(None) => pending.push(index),
7565                Err(error) => results[index] = Some(Err(error)),
7566            }
7567        }
7568
7569        while !pending.is_empty() {
7570            if pending.len() == 1 {
7571                let index = pending[0];
7572                results[index] = Some(self.execute_profile_member_locked(&members[index]));
7573                break;
7574            }
7575            let commands = pending
7576                .iter()
7577                .map(|index| match &members[*index].operation {
7578                    QueuedOperation::Kv(command) => command.clone(),
7579                    _ => unreachable!("KV pending members were validated above"),
7580                })
7581                .collect::<Vec<_>>();
7582            let full_payload = match encode_replicated_kv_batch(&commands) {
7583                Ok(payload) => payload,
7584                Err(error) => {
7585                    let error = NodeError::InvalidRequest(error.to_string());
7586                    for index in pending.drain(..) {
7587                        results[index] = Some(Err(error.clone()));
7588                    }
7589                    break;
7590                }
7591            };
7592            let (proposal_count, proposal_payload) = if full_payload.len() <= MAX_COMMAND_BYTES {
7593                (commands.len(), full_payload)
7594            } else {
7595                let Some(prefix) = largest_fitting_kv_batch_prefix(&commands) else {
7596                    let index = pending.remove(0);
7597                    results[index] = Some(self.execute_profile_member_locked(&members[index]));
7598                    continue;
7599                };
7600                prefix
7601            };
7602            let proposed_indices = pending[..proposal_count].to_vec();
7603            let (last_index, last_hash) = match self.ensure_materialized_tip() {
7604                Ok(tip) => tip,
7605                Err(error) => {
7606                    for index in pending.drain(..) {
7607                        results[index] = Some(Err(error.clone()));
7608                    }
7609                    break;
7610                }
7611            };
7612            let slot = match last_index.checked_add(1) {
7613                Some(slot) => slot,
7614                None => {
7615                    let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7616                    for index in pending.drain(..) {
7617                        results[index] = Some(Err(error.clone()));
7618                    }
7619                    break;
7620                }
7621            };
7622            let entry = match self.consensus.propose_at_cancellable(
7623                slot,
7624                last_hash,
7625                Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7626                &self.operation_cancelled,
7627            ) {
7628                Ok(entry) => entry,
7629                Err(error) => {
7630                    let error = self.map_consensus_error(error);
7631                    for index in pending.drain(..) {
7632                        results[index] = Some(Err(error.clone()));
7633                    }
7634                    break;
7635                }
7636            };
7637            if let Err(error) = self.persist_entry(&entry, slot, last_hash) {
7638                for index in pending.drain(..) {
7639                    results[index] = Some(Err(error.clone()));
7640                }
7641                break;
7642            }
7643
7644            let current_pending = std::mem::take(&mut pending);
7645            let post_commit = match self.check_kv_members_bulk(members, &current_pending) {
7646                Ok(post_commit) => post_commit,
7647                Err(error) => {
7648                    for index in current_pending {
7649                        results[index] = Some(Err(error.clone()));
7650                    }
7651                    break;
7652                }
7653            };
7654            let mut remaining = Vec::new();
7655            for (index, lookup) in post_commit {
7656                match lookup {
7657                    Ok(Some(record)) => {
7658                        results[index] = Some(Ok(ClientWriteResponse::Kv(
7659                            KvMutationOutcome::from_record(record),
7660                        )));
7661                    }
7662                    Ok(None) => remaining.push(index),
7663                    Err(error) => results[index] = Some(Err(error)),
7664                }
7665            }
7666            if entry.entry_type == EntryType::Command
7667                && entry.payload == proposal_payload
7668                && remaining
7669                    .iter()
7670                    .any(|index| proposed_indices.contains(index))
7671            {
7672                let error = self.latch(NodeError::Invariant(
7673                    "committed KV batch did not record every request".into(),
7674                ));
7675                for index in remaining.drain(..) {
7676                    results[index] = Some(Err(error.clone()));
7677                }
7678            }
7679            pending = remaining;
7680        }
7681
7682        for (index, canonical) in aliases.into_iter().enumerate() {
7683            if let Some(canonical) = canonical {
7684                results[index] = results[canonical].clone();
7685            }
7686        }
7687
7688        results
7689            .into_iter()
7690            .map(|result| {
7691                result.unwrap_or_else(|| {
7692                    Err(self.latch(NodeError::Invariant(
7693                        "KV writer batch omitted a request result".into(),
7694                    )))
7695                })
7696            })
7697            .collect()
7698    }
7699
7700    #[cfg(feature = "kv")]
7701    #[allow(clippy::infallible_destructuring_match)]
7702    fn check_kv_members_bulk(
7703        &self,
7704        members: &[RuntimeBatchMember],
7705        indices: &[usize],
7706    ) -> Result<Vec<KvMemberCheck>, NodeError> {
7707        if indices.is_empty() {
7708            return Ok(Vec::new());
7709        }
7710        let materializer = self.lock_materializer()?;
7711        let kv = match &*materializer {
7712            Materializer::Kv(kv) => kv,
7713            #[cfg(feature = "sql")]
7714            Materializer::Sql(_) => {
7715                return Err(NodeError::ExecutionProfileMismatch {
7716                    expected: ExecutionProfile::Kv,
7717                    actual: ExecutionProfile::Sqlite,
7718                });
7719            }
7720            #[cfg(feature = "graph")]
7721            Materializer::Graph(_) => {
7722                return Err(NodeError::ExecutionProfileMismatch {
7723                    expected: ExecutionProfile::Kv,
7724                    actual: ExecutionProfile::Graph,
7725                });
7726            }
7727        };
7728        let requests = indices
7729            .iter()
7730            .map(|index| {
7731                let command = match &members[*index].operation {
7732                    QueuedOperation::Kv(command) => command,
7733                    #[cfg(feature = "sql")]
7734                    QueuedOperation::KeyValue { .. } | QueuedOperation::Sql(_) => {
7735                        unreachable!("KV receipt lookup contains only KV members")
7736                    }
7737                    #[cfg(feature = "graph")]
7738                    QueuedOperation::Graph(_) => {
7739                        unreachable!("KV receipt lookup contains only KV members")
7740                    }
7741                };
7742                (command.request_id(), members[*index].payload.as_slice())
7743            })
7744            .collect::<Vec<_>>();
7745        let lookups = kv
7746            .check_requests(&requests)
7747            .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
7748        if lookups.len() != indices.len() {
7749            return Err(self.latch(NodeError::Invariant(
7750                "KV bulk receipt lookup returned a misaligned result vector".into(),
7751            )));
7752        }
7753        Ok(indices
7754            .iter()
7755            .copied()
7756            .zip(
7757                lookups.into_iter().map(|lookup| {
7758                    lookup.map_err(|error| NodeError::InvalidRequest(error.to_string()))
7759                }),
7760            )
7761            .collect())
7762    }
7763
7764    fn execute_profile_member_locked(
7765        &self,
7766        member: &RuntimeBatchMember,
7767    ) -> Result<ClientWriteResponse, NodeError> {
7768        match &member.operation {
7769            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
7770            QueuedOperation::Unavailable => unreachable!("no execution profiles are compiled in"),
7771            #[cfg(feature = "graph")]
7772            QueuedOperation::Graph(command) => self
7773                .mutate_graph_payload_locked(command, member.payload.clone())
7774                .map(ClientWriteResponse::Graph),
7775            #[cfg(feature = "kv")]
7776            QueuedOperation::Kv(command) => self
7777                .mutate_kv_payload_locked(command, member.payload.clone())
7778                .map(ClientWriteResponse::Kv),
7779            #[cfg(feature = "sql")]
7780            QueuedOperation::KeyValue { .. } | QueuedOperation::Sql(_) => {
7781                Err(NodeError::ExecutionProfileMismatch {
7782                    expected: self.config.execution_profile,
7783                    actual: ExecutionProfile::Sqlite,
7784                })
7785            }
7786        }
7787    }
7788
7789    #[cfg(feature = "sql")]
7790    fn execute_single_member_locked(
7791        &self,
7792        member: &RuntimeBatchMember,
7793    ) -> Result<ClientWriteResponse, NodeError> {
7794        if let QueuedOperation::Sql(command) = &member.operation {
7795            self.execute_sql_payload_locked(command, member.payload.clone())
7796                .map(|outcome| {
7797                    ClientWriteResponse::Sql(sql_execute_response(
7798                        outcome.response,
7799                        outcome.sql_result,
7800                    ))
7801                })
7802        } else if let QueuedOperation::KeyValue { key, value } = &member.operation {
7803            self.execute_put_payload_locked(&member.request_id, key, value, member.payload.clone())
7804                .map(|outcome| ClientWriteResponse::KeyValue(outcome.response))
7805        } else {
7806            Err(NodeError::ExecutionProfileMismatch {
7807                expected: ExecutionProfile::Sqlite,
7808                actual: self.config.execution_profile,
7809            })
7810        }
7811    }
7812
7813    #[cfg(feature = "sql")]
7814    fn execute_sql_payload_locked(
7815        &self,
7816        command: &SqlCommand,
7817        request_payload: Vec<u8>,
7818    ) -> Result<ExecutedPayload, NodeError> {
7819        self.ensure_ready()?;
7820        self.ensure_writes_active()?;
7821        if let Some(outcome) = self.check_request(&command.request_id, &request_payload)? {
7822            return Ok(ExecutedPayload {
7823                response: write_response(outcome),
7824                sql_result: self.replay_sql_result(
7825                    &command.request_id,
7826                    &request_payload,
7827                    outcome,
7828                )?,
7829            });
7830        }
7831
7832        loop {
7833            let (last_index, last_hash) = self.ensure_materialized_tip()?;
7834            let proposal_payload =
7835                self.prepare_sql_proposal(command, &request_payload, last_index, last_hash)?;
7836            let slot = last_index.checked_add(1).ok_or_else(|| {
7837                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
7838            })?;
7839            let entry = self
7840                .consensus
7841                .propose_at_cancellable(
7842                    slot,
7843                    last_hash,
7844                    Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7845                    &self.operation_cancelled,
7846                )
7847                .map_err(|error| self.map_consensus_error(error))?;
7848            let sql_result = self.persist_entry(&entry, slot, last_hash)?;
7849            if let Some(outcome) = self.check_request(&command.request_id, &request_payload)? {
7850                return Ok(ExecutedPayload {
7851                    response: write_response(outcome),
7852                    sql_result: sql_result.or(self.replay_sql_result(
7853                        &command.request_id,
7854                        &request_payload,
7855                        outcome,
7856                    )?),
7857                });
7858            }
7859            if entry.entry_type == EntryType::Command && entry.payload == proposal_payload {
7860                return Err(self.latch(NodeError::Invariant(
7861                    "committed SQL request was not recorded by SQLite".into(),
7862                )));
7863            }
7864        }
7865    }
7866
7867    #[cfg(feature = "sql")]
7868    fn prepare_sql_proposal(
7869        &self,
7870        command: &SqlCommand,
7871        request_payload: &[u8],
7872        base_index: LogIndex,
7873        base_hash: LogHash,
7874    ) -> Result<Vec<u8>, NodeError> {
7875        let sqlite = self.lock_sqlite()?;
7876        let preparation = sqlite.prepare_sql_batch_effect(
7877            &[SqlBatchMember {
7878                command,
7879                request_payload,
7880            }],
7881            base_index,
7882            base_hash,
7883        );
7884        let preparation = match preparation {
7885            Ok(preparation) => preparation,
7886            Err(rhiza_sql::Error::ResourceExhausted(message)) => {
7887                return Err(NodeError::ResourceExhausted(message));
7888            }
7889            Err(error) => return Err(self.map_sqlite_error(error)),
7890        };
7891        let result = preparation
7892            .results
7893            .into_iter()
7894            .next()
7895            .expect("one-member SQL preparation returns one result");
7896        if let Err(error) = result {
7897            if let rhiza_sql::Error::ResourceExhausted(message) = error {
7898                return Err(NodeError::ResourceExhausted(message));
7899            }
7900            if let rhiza_sql::Error::RequestConflict(conflict) = error {
7901                return Err(NodeError::RequestConflict(conflict));
7902            }
7903            let message = error.to_string();
7904            let statement_index = first_invalid_sql_statement(command, |prefix| {
7905                let Ok(prefix_payload) = encode_sql_command(prefix) else {
7906                    return true;
7907                };
7908                let prefix_member = [SqlBatchMember {
7909                    command: prefix,
7910                    request_payload: &prefix_payload,
7911                }];
7912                match sqlite.prepare_sql_batch_effect(&prefix_member, base_index, base_hash) {
7913                    Ok(preparation) => preparation
7914                        .results
7915                        .into_iter()
7916                        .next()
7917                        .is_none_or(|result| result.is_err()),
7918                    Err(_) => true,
7919                }
7920            });
7921            return match statement_index {
7922                Some(statement_index) => Err(NodeError::InvalidSqlStatement {
7923                    statement_index,
7924                    message,
7925                }),
7926                None => Err(NodeError::InvalidRequest(message)),
7927            };
7928        }
7929        let payload = preparation.effect.ok_or_else(|| {
7930            self.latch(NodeError::Invariant(
7931                "successful SQL preparation omitted its QWAL v3 effect".into(),
7932            ))
7933        })?;
7934        if !payload.starts_with(QWAL_V3_MAGIC) {
7935            return Err(self.latch(NodeError::Invariant(
7936                "SQLite materializer prepared a non-QWAL v3 SQL proposal".into(),
7937            )));
7938        }
7939        if payload.len() > MAX_COMMAND_BYTES {
7940            return Err(NodeError::ResourceExhausted(format!(
7941                "SQL effect exceeds {MAX_COMMAND_BYTES} bytes"
7942            )));
7943        }
7944        Ok(payload)
7945    }
7946
7947    #[cfg(feature = "sql")]
7948    fn execute_put_payload_locked(
7949        &self,
7950        request_id: &str,
7951        key: &str,
7952        value: &str,
7953        payload: Vec<u8>,
7954    ) -> Result<ExecutedPayload, NodeError> {
7955        self.ensure_ready()?;
7956        self.ensure_writes_active()?;
7957
7958        if let Some(outcome) = self.check_request(request_id, &payload)? {
7959            return Ok(ExecutedPayload {
7960                response: write_response(outcome),
7961                sql_result: None,
7962            });
7963        }
7964
7965        loop {
7966            let (last_index, last_hash) = self.ensure_materialized_tip()?;
7967            let proposal_payload = self
7968                .lock_sqlite()?
7969                .prepare_put_effect(request_id, key, value, &payload, last_index, last_hash)
7970                .map_err(|error| self.map_sqlite_error(error))?;
7971            if !proposal_payload.starts_with(QWAL_V3_MAGIC) {
7972                return Err(self.latch(NodeError::Invariant(
7973                    "SQLite materializer prepared a non-QWAL v3 legacy put proposal".into(),
7974                )));
7975            }
7976            if proposal_payload.len() > MAX_COMMAND_BYTES {
7977                return Err(NodeError::ResourceExhausted(format!(
7978                    "SQLite QWAL effect exceeds {MAX_COMMAND_BYTES} bytes"
7979                )));
7980            }
7981            let slot = last_index.checked_add(1).ok_or_else(|| {
7982                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
7983            })?;
7984            let entry = self
7985                .consensus
7986                .propose_at_cancellable(
7987                    slot,
7988                    last_hash,
7989                    Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7990                    &self.operation_cancelled,
7991                )
7992                .map_err(|error| self.map_consensus_error(error))?;
7993            self.persist_entry(&entry, slot, last_hash)?;
7994
7995            if let Some(outcome) = self.check_request(request_id, &payload)? {
7996                return Ok(ExecutedPayload {
7997                    response: write_response(outcome),
7998                    sql_result: None,
7999                });
8000            }
8001            if entry.entry_type == EntryType::Command && entry.payload == proposal_payload {
8002                return Err(self.latch(NodeError::Invariant(
8003                    "committed legacy put request was not recorded by SQLite QWAL".into(),
8004                )));
8005            }
8006        }
8007    }
8008
8009    #[cfg(feature = "sql")]
8010    fn replay_sql_result(
8011        &self,
8012        request_id: &str,
8013        payload: &[u8],
8014        outcome: RequestOutcome,
8015    ) -> Result<Option<SqlCommandResult>, NodeError> {
8016        let sqlite = self.lock_sqlite()?;
8017        let stored = sqlite
8018            .check_sql_request(request_id, payload)
8019            .map_err(|error| self.map_sqlite_error(error))?
8020            .ok_or_else(|| {
8021                self.latch(NodeError::Invariant(
8022                    "committed SQL request result is missing".into(),
8023                ))
8024            })?;
8025        if stored.0 != outcome {
8026            return Err(self.latch(NodeError::Invariant(
8027                "stored SQL request outcome changed".into(),
8028            )));
8029        }
8030        Ok(stored.1)
8031    }
8032
8033    #[cfg(feature = "sql")]
8034    fn map_sql_batch_member_error(&self, error: rhiza_sql::Error) -> NodeError {
8035        match error {
8036            rhiza_sql::Error::RequestConflict(conflict) => NodeError::RequestConflict(conflict),
8037            rhiza_sql::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
8038            rhiza_sql::Error::InvalidCommand(message) | rhiza_sql::Error::Sqlite(message) => {
8039                NodeError::InvalidSqlStatement {
8040                    statement_index: 0,
8041                    message,
8042                }
8043            }
8044            other => self.map_sqlite_error(other),
8045        }
8046    }
8047
8048    #[cfg(feature = "sql")]
8049    pub fn read(&self, key: &str, consistency: ReadConsistency) -> Result<ReadResponse, NodeError> {
8050        validate_key(key)?;
8051        match consistency {
8052            ReadConsistency::Local => self.read_local(key, None),
8053            ReadConsistency::AppliedIndex(required) => self.read_local(key, Some(required)),
8054            ReadConsistency::ReadBarrier => {
8055                let anchor = self.establish_read_barrier()?;
8056                let _commit = self.lock_commit()?;
8057                self.ensure_ready()?;
8058                self.ensure_writes_active()?;
8059                self.validate_read_barrier_descendant_locked(anchor)?;
8060                self.read_local(key, Some(anchor.index()))
8061            }
8062        }
8063    }
8064
8065    #[cfg(feature = "sql")]
8066    pub fn query_sql(
8067        &self,
8068        statement: &SqlStatement,
8069        consistency: ReadConsistency,
8070        max_rows: u32,
8071    ) -> Result<SqlQueryResponse, NodeError> {
8072        if max_rows == 0 || max_rows > MAX_SQL_MAX_ROWS {
8073            return Err(NodeError::InvalidRequest(format!(
8074                "max_rows must be between 1 and {MAX_SQL_MAX_ROWS}"
8075            )));
8076        }
8077        match consistency {
8078            ReadConsistency::Local => self.query_sql_local(statement, None, max_rows),
8079            ReadConsistency::AppliedIndex(required) => {
8080                self.query_sql_local(statement, Some(required), max_rows)
8081            }
8082            ReadConsistency::ReadBarrier => {
8083                let anchor = self.establish_read_barrier()?;
8084                let _commit = self.lock_commit()?;
8085                self.ensure_ready()?;
8086                self.ensure_writes_active()?;
8087                self.validate_read_barrier_descendant_locked(anchor)?;
8088                self.query_sql_local(statement, Some(anchor.index()), max_rows)
8089            }
8090        }
8091    }
8092
8093    pub fn applied_index(&self) -> Result<LogIndex, NodeError> {
8094        self.ensure_ready()?;
8095        self.lock_materializer()?
8096            .applied_index()
8097            .map_err(|error| self.latch(NodeError::Storage(error)))
8098    }
8099
8100    pub fn applied_hash(&self) -> Result<LogHash, NodeError> {
8101        self.ensure_ready()?;
8102        self.lock_materializer()?
8103            .applied_hash()
8104            .map_err(|error| self.latch(NodeError::Storage(error)))
8105    }
8106
8107    pub fn cancel_operations(&self) {
8108        self.operation_cancelled.store(true, Ordering::Release);
8109        #[cfg(feature = "sql")]
8110        self.sql_group_commit.fail_pending(NodeError::Unavailable(
8111            "SQL group commit cancelled during shutdown".into(),
8112        ));
8113        #[cfg(feature = "kv")]
8114        self.kv_group_commit.fail_pending(NodeError::Unavailable(
8115            "KV group commit cancelled during shutdown".into(),
8116        ));
8117        self.read_barriers.cancel_waiters();
8118        self.operation_cancelled_notify.notify_waiters();
8119    }
8120
8121    pub fn materialize_next_decision(&self) -> Result<bool, NodeError> {
8122        let _commit = self.lock_commit()?;
8123        self.ensure_ready()?;
8124        let (last_index, last_hash) = self.ensure_materialized_tip()?;
8125        let slot = last_index
8126            .checked_add(1)
8127            .ok_or_else(|| self.latch(NodeError::Invariant("qlog index is exhausted".into())))?;
8128        match self
8129            .consensus
8130            .inspect_decision_at(slot, last_hash)
8131            .map_err(|error| self.map_consensus_error(error))?
8132        {
8133            DecisionInspection::Committed(entry) => {
8134                self.persist_entry(&entry, slot, last_hash)?;
8135                Ok(true)
8136            }
8137            DecisionInspection::Empty | DecisionInspection::Pending => Ok(false),
8138            DecisionInspection::Unavailable => Err(NodeError::Unavailable(
8139                "decision proof inspection did not reach quorum".into(),
8140            )),
8141        }
8142    }
8143
8144    pub async fn run_background_materializer<F>(
8145        self: Arc<Self>,
8146        poll_interval: Duration,
8147        shutdown: F,
8148    ) -> Result<(), NodeError>
8149    where
8150        F: std::future::Future<Output = ()> + Send,
8151    {
8152        let poll_interval = poll_interval.max(Duration::from_millis(10));
8153        tokio::pin!(shutdown);
8154        loop {
8155            tokio::select! {
8156                () = &mut shutdown => return Ok(()),
8157                () = tokio::time::sleep(poll_interval) => {
8158                    loop {
8159                        let runtime = Arc::clone(&self);
8160                        let mut operation = tokio::task::spawn_blocking(move || runtime.materialize_next_decision());
8161                        let (result, shutting_down) = tokio::select! {
8162                            () = &mut shutdown => {
8163                                self.cancel_operations();
8164                                (operation.await, true)
8165                            }
8166                            result = &mut operation => (result, false),
8167                        };
8168                        if shutting_down {
8169                            return match result {
8170                                Ok(Ok(_) | Err(NodeError::Unavailable(_) | NodeError::Contention(_))) => Ok(()),
8171                                Ok(Err(error)) => Err(error),
8172                                Err(error) => Err(NodeError::Fatal(format!("materializer task failed: {error}"))),
8173                            };
8174                        }
8175                        match result {
8176                            Ok(Ok(true)) => continue,
8177                            Ok(Ok(false) | Err(NodeError::Unavailable(_) | NodeError::Contention(_))) => break,
8178                            Ok(Err(error)) => return Err(error),
8179                            Err(error) => return Err(NodeError::Fatal(format!("materializer task failed: {error}"))),
8180                        }
8181                    }
8182                }
8183            }
8184        }
8185    }
8186
8187    pub const fn config(&self) -> &NodeConfig {
8188        &self.config
8189    }
8190
8191    pub const fn consensus(&self) -> &Arc<ThreeNodeConsensus> {
8192        &self.consensus
8193    }
8194
8195    pub const fn log_store(&self) -> &FileLogStore {
8196        &self.log_store
8197    }
8198
8199    pub fn configuration_state(&self) -> Result<ConfigurationState, NodeError> {
8200        self.log_store
8201            .configuration_state()
8202            .map_err(|error| NodeError::Storage(error.to_string()))
8203    }
8204
8205    pub fn status(&self) -> Result<NodeStatus, NodeError> {
8206        let configuration_state = self.configuration_state()?;
8207        let (configuration_status, active_config_id) = if configuration_state.is_active() {
8208            (
8209                RuntimeConfigurationStatus::Active,
8210                configuration_state.config_id(),
8211            )
8212        } else if configuration_state.config_id() == self.consensus.config_id() {
8213            (
8214                RuntimeConfigurationStatus::Stopped,
8215                configuration_state.config_id(),
8216            )
8217        } else {
8218            (
8219                RuntimeConfigurationStatus::AwaitingActivation,
8220                configuration_state
8221                    .config_id()
8222                    .checked_add(1)
8223                    .ok_or_else(|| {
8224                        NodeError::Invariant("successor configuration id is exhausted".into())
8225                    })?,
8226            )
8227        };
8228        Ok(NodeStatus {
8229            ready: self.is_ready(),
8230            stop_anchor: configuration_state.stop().copied(),
8231            active_config_id,
8232            active_membership_digest: self.config.membership.digest(),
8233            configuration_status,
8234            configuration_state,
8235        })
8236    }
8237
8238    pub fn stop_current_configuration(&self) -> Result<StopInformation, NodeError> {
8239        let _commit = self.lock_commit()?;
8240        self.stop_current_configuration_locked(None)
8241    }
8242
8243    pub fn stop_current_configuration_for_successor(
8244        &self,
8245        successor: &Membership,
8246    ) -> Result<StopInformation, NodeError> {
8247        let _commit = self.lock_commit()?;
8248        self.stop_current_configuration_locked(Some(successor))
8249    }
8250
8251    pub fn stop_current_configuration_if(
8252        &self,
8253        expected_config_id: u64,
8254    ) -> Result<StopInformation, NodeError> {
8255        let _commit = self.lock_commit()?;
8256        let state = self.configuration_state()?;
8257        if !state.is_active() || state.config_id() != expected_config_id {
8258            return Err(NodeError::PreconditionFailed(
8259                "active configuration does not match expected_config_id".into(),
8260            ));
8261        }
8262        self.stop_current_configuration_locked(None)
8263    }
8264
8265    fn stop_current_configuration_locked(
8266        &self,
8267        successor: Option<&Membership>,
8268    ) -> Result<StopInformation, NodeError> {
8269        self.ensure_ready()?;
8270        self.ensure_writes_active()?;
8271        let state = self.configuration_state()?;
8272        let stop_command = match successor {
8273            Some(successor) => ConfigChange::bound_stop(
8274                self.config.cluster_id.clone(),
8275                state.config_id(),
8276                state.digest(),
8277                state.config_id().checked_add(1).ok_or_else(|| {
8278                    NodeError::Invariant("successor config id is exhausted".into())
8279                })?,
8280                successor.members().to_vec(),
8281            )
8282            .map_err(|error| NodeError::Invariant(error.to_string()))?
8283            .to_stored_command(),
8284            None => ConfigChange::stop(state.config_id(), state.digest()).to_stored_command(),
8285        };
8286        loop {
8287            let (last_index, last_hash) = self.ensure_materialized_tip()?;
8288            let slot = last_index
8289                .checked_add(1)
8290                .ok_or_else(|| NodeError::Invariant("qlog index is exhausted".into()))?;
8291            let entry = self
8292                .consensus
8293                .propose_stored_at(slot, last_hash, stop_command.clone())
8294                .map_err(|error| self.map_consensus_error(error))?;
8295            self.persist_entry(&entry, slot, last_hash)?;
8296            let decided = StoredCommand::new(entry.entry_type, entry.payload.clone());
8297            if decided != stop_command {
8298                let current = self.configuration_state()?;
8299                if current.is_active() {
8300                    continue;
8301                }
8302                return Err(NodeError::ConfigurationTransition {
8303                    state: Box::new(current),
8304                });
8305            }
8306            let proof = self
8307                .consensus
8308                .inspect_decision_proof_at(slot)
8309                .map_err(|error| self.map_consensus_error(error))?
8310                .ok_or_else(|| {
8311                    NodeError::Unavailable("durable Stop proof is unavailable".into())
8312                })?;
8313            if proof
8314                .proposal()
8315                .value
8316                .as_ref()
8317                .map(|value| value.entry_hash)
8318                != Some(entry.hash)
8319            {
8320                return Err(self.latch(NodeError::Reconciliation(
8321                    "Stop proof differs from committed stop entry".into(),
8322                )));
8323            }
8324            return Ok(StopInformation {
8325                version: 2,
8326                entry,
8327                proof,
8328            });
8329        }
8330    }
8331
8332    pub fn activate_successor(&self) -> Result<LogEntry, NodeError> {
8333        let _commit = self.lock_commit()?;
8334        self.activate_successor_locked(None)
8335    }
8336
8337    pub fn activate_successor_if(&self, expected_config_id: u64) -> Result<LogEntry, NodeError> {
8338        let _commit = self.lock_commit()?;
8339        self.activate_successor_locked(Some(expected_config_id))
8340    }
8341
8342    fn activate_successor_locked(
8343        &self,
8344        expected_config_id: Option<u64>,
8345    ) -> Result<LogEntry, NodeError> {
8346        self.ensure_ready()?;
8347        let state = self.configuration_state()?;
8348        let stop = state
8349            .stop()
8350            .copied()
8351            .ok_or_else(|| NodeError::ConfigurationTransition {
8352                state: Box::new(state.clone()),
8353            })?;
8354        if state.config_id() == self.consensus.config_id() {
8355            return Err(NodeError::ConfigurationTransition {
8356                state: Box::new(state),
8357            });
8358        }
8359        let successor_config_id = state.config_id().checked_add(1).ok_or_else(|| {
8360            NodeError::Invariant("successor configuration id is exhausted".into())
8361        })?;
8362        if expected_config_id.is_some_and(|expected| expected != successor_config_id) {
8363            return Err(NodeError::PreconditionFailed(
8364                "successor configuration does not match expected_config_id".into(),
8365            ));
8366        }
8367        let stop_entry = self.recover_stop_entry(stop)?;
8368        let entry = self
8369            .consensus
8370            .propose_activation_for_stop_entry(&stop_entry)
8371            .map_err(|error| self.map_consensus_error(error))?;
8372        self.persist_entry(&entry, stop.index() + 1, stop.hash())?;
8373        Ok(entry)
8374    }
8375
8376    pub(crate) fn recover_stop_entry(&self, stop: LogAnchor) -> Result<LogEntry, NodeError> {
8377        if let Some(entry) = self
8378            .log_store
8379            .read(stop.index())
8380            .map_err(|error| NodeError::Storage(error.to_string()))?
8381            .filter(|entry| entry.hash == stop.hash())
8382        {
8383            return Ok(entry);
8384        }
8385        if let Some(entry) = self
8386            .config
8387            .predecessor_stop_entry
8388            .as_ref()
8389            .filter(|entry| entry.index == stop.index() && entry.hash == stop.hash())
8390        {
8391            validate_entry_envelope(&self.config, entry, entry.index, entry.prev_hash)?;
8392            return Ok(entry.clone());
8393        }
8394        let proof = self
8395            .consensus
8396            .inspect_decision_proof_at(stop.index())
8397            .map_err(|error| self.map_consensus_error(error))?
8398            .ok_or_else(|| NodeError::Unavailable("durable Stop proof is unavailable".into()))?;
8399        let value = proof
8400            .proposal()
8401            .value
8402            .as_ref()
8403            .filter(|value| value.entry_hash == stop.hash())
8404            .ok_or_else(|| {
8405                self.latch(NodeError::Reconciliation(
8406                    "Stop proof differs from compacted anchor".into(),
8407                ))
8408            })?;
8409        match self
8410            .consensus
8411            .inspect_decision_at(stop.index(), value.prev_hash)
8412            .map_err(|error| self.map_consensus_error(error))?
8413        {
8414            DecisionInspection::Committed(entry) if entry.hash == stop.hash() => Ok(entry),
8415            DecisionInspection::Unavailable => Err(NodeError::Unavailable(
8416                "durable Stop command is unavailable".into(),
8417            )),
8418            DecisionInspection::Committed(_)
8419            | DecisionInspection::Empty
8420            | DecisionInspection::Pending => Err(self.latch(NodeError::Reconciliation(
8421                "Stop decision differs from compacted anchor".into(),
8422            ))),
8423        }
8424    }
8425
8426    pub fn log_root(&self) -> Result<LogAnchor, NodeError> {
8427        let _commit = self.lock_commit()?;
8428        self.log_root_unlocked()
8429    }
8430
8431    fn log_root_unlocked(&self) -> Result<LogAnchor, NodeError> {
8432        let state = self
8433            .log_store
8434            .logical_state()
8435            .map_err(|error| NodeError::Storage(error.to_string()))?;
8436        Ok(state.tip.map_or_else(
8437            || {
8438                state
8439                    .anchor
8440                    .map_or(LogAnchor::new(0, LogHash::ZERO), |anchor| {
8441                        *anchor.compacted()
8442                    })
8443            },
8444            |entry| LogAnchor::new(entry.index(), entry.hash()),
8445        ))
8446    }
8447
8448    pub fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
8449        fetch_runtime_log(self, request)
8450    }
8451
8452    #[cfg(feature = "sql")]
8453    pub fn create_recovery_snapshot(&self) -> Result<RecoverySnapshot, NodeError> {
8454        let _commit = self.lock_commit()?;
8455        self.ensure_ready()?;
8456        self.ensure_materialized_tip()?;
8457        self.lock_sqlite()?
8458            .create_recovery_snapshot(self.config.recovery_generation)
8459            .map_err(|error| self.map_sqlite_error(error))
8460    }
8461
8462    pub async fn checkpoint_compact(
8463        &self,
8464        coordinator: &CheckpointCoordinator,
8465    ) -> Result<RecoveryAnchor, DurabilityError> {
8466        coordinator.checkpoint_compact(self).await
8467    }
8468
8469    #[cfg_attr(not(any(feature = "sql", feature = "kv")), allow(unused_variables))]
8470    pub(crate) fn compact_embedded_log_before(
8471        &self,
8472        anchor_index: LogIndex,
8473    ) -> Result<(), NodeError> {
8474        let materializer = self.lock_materializer()?;
8475        match &*materializer {
8476            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
8477            Materializer::Unavailable => unreachable!("no execution profiles are compiled in"),
8478            #[cfg(feature = "sql")]
8479            Materializer::Sql(sql) => sql
8480                .compact_embedded_log_before(anchor_index)
8481                .map_err(|error| self.map_sqlite_error(error)),
8482            #[cfg(feature = "kv")]
8483            Materializer::Kv(kv) => kv
8484                .compact_embedded_log_before(anchor_index)
8485                .map_err(|error| NodeError::Storage(error.to_string())),
8486            #[cfg(feature = "graph")]
8487            Materializer::Graph(_) => Ok(()),
8488        }
8489    }
8490
8491    #[cfg(feature = "sql")]
8492    pub fn verify_snapshot_publication(
8493        &self,
8494        snapshot: &RecoverySnapshot,
8495        publication: &SnapshotRecord,
8496    ) -> Result<VerifiedSnapshotPublication, NodeError> {
8497        let anchor = snapshot.anchor();
8498        let manifest = publication.manifest();
8499        let publication_digest = LogHash::from_hex(publication.sha256()).ok_or_else(|| {
8500            NodeError::Reconciliation("published snapshot digest is invalid".into())
8501        })?;
8502        if anchor.cluster_id() != self.config.cluster_id
8503            || anchor.epoch() != self.config.epoch
8504            || anchor.config_id() != self.config.config_id()
8505            || anchor.recovery_generation() != self.config.recovery_generation
8506            || manifest.cluster_id() != anchor.cluster_id()
8507            || manifest.epoch() != anchor.epoch()
8508            || manifest.config_id() != anchor.config_id()
8509            || manifest.index() != anchor.compacted().index()
8510            || manifest.applied_hash() != anchor.compacted().hash()
8511            || manifest.snapshot_id() != anchor.snapshot().snapshot_id()
8512            || publication_digest != anchor.snapshot().digest()
8513            || publication.size_bytes() != anchor.snapshot().size_bytes()
8514            || LogHash::digest(&[snapshot.db_bytes()]) != anchor.snapshot().digest()
8515            || snapshot.db_bytes().len() as u64 != anchor.snapshot().size_bytes()
8516        {
8517            return Err(NodeError::Reconciliation(
8518                "published snapshot does not match the runtime recovery anchor".into(),
8519            ));
8520        }
8521        Ok(VerifiedSnapshotPublication {
8522            anchor: anchor.clone(),
8523        })
8524    }
8525
8526    #[cfg(feature = "sql")]
8527    pub fn compact_log(&self, publication: &VerifiedSnapshotPublication) -> Result<(), NodeError> {
8528        let _commit = self.lock_commit()?;
8529        self.ensure_ready()?;
8530        let applied_index = self.applied_index()?;
8531        let applied_hash = self.applied_hash()?;
8532        let anchor = &publication.anchor;
8533        if anchor.cluster_id() != self.config.cluster_id
8534            || anchor.epoch() != self.config.epoch
8535            || anchor.config_id() != self.config.config_id()
8536            || anchor.recovery_generation() != self.config.recovery_generation
8537            || anchor.compacted().index() != applied_index
8538            || anchor.compacted().hash() != applied_hash
8539        {
8540            return Err(NodeError::Reconciliation(
8541                "verified snapshot anchor does not match the current applied entry".into(),
8542            ));
8543        }
8544        self.log_store
8545            .compact_prefix(anchor)
8546            .map_err(|error| NodeError::Storage(error.to_string()))?;
8547        self.compact_embedded_log_before(anchor.compacted().index())
8548    }
8549
8550    pub fn is_ready(&self) -> bool {
8551        self.ready.load(Ordering::Acquire)
8552            && !self.fatal.load(Ordering::Acquire)
8553            && !self.checkpointing.load(Ordering::Acquire)
8554    }
8555
8556    pub fn is_fatal(&self) -> bool {
8557        self.fatal.load(Ordering::Acquire)
8558    }
8559
8560    pub fn fatal_reason(&self) -> Option<String> {
8561        self.fatal_reason
8562            .lock()
8563            .ok()
8564            .and_then(|reason| reason.clone())
8565    }
8566
8567    #[cfg(feature = "sql")]
8568    fn read_local(
8569        &self,
8570        key: &str,
8571        required_index: Option<LogIndex>,
8572    ) -> Result<ReadResponse, NodeError> {
8573        self.ensure_ready()?;
8574        let sqlite = self.lock_sqlite()?;
8575        let (applied_index, hash) = sqlite
8576            .applied_tip_value()
8577            .map_err(|error| self.map_sqlite_error(error))?;
8578        if required_index.is_some_and(|required| applied_index < required) {
8579            return Err(NodeError::Unavailable(format!(
8580                "local applied index {applied_index} has not reached {}",
8581                required_index.expect("checked above")
8582            )));
8583        }
8584        let value = sqlite
8585            .get_value(key)
8586            .map_err(|error| self.map_sqlite_error(error))?;
8587        Ok(ReadResponse {
8588            value,
8589            applied_index,
8590            hash,
8591        })
8592    }
8593
8594    #[cfg(feature = "graph")]
8595    fn get_graph_document_local(
8596        &self,
8597        id: &str,
8598        required_index: Option<LogIndex>,
8599    ) -> Result<GraphReadResponse, NodeError> {
8600        self.ensure_ready()?;
8601        let graph = self.graph_materializer()?;
8602        let (value, applied_index, hash) = graph
8603            .get_document_with_tip(id)
8604            .map_err(|error| self.map_graph_read_error(error))?;
8605        if required_index.is_some_and(|required| applied_index < required) {
8606            return Err(NodeError::Unavailable(format!(
8607                "local applied index {applied_index} has not reached {}",
8608                required_index.expect("checked above")
8609            )));
8610        }
8611        Ok(GraphReadResponse {
8612            value,
8613            applied_index,
8614            hash,
8615        })
8616    }
8617
8618    #[cfg(feature = "graph")]
8619    fn query_graph_local(
8620        &self,
8621        statement: &str,
8622        parameters: &BTreeMap<String, GraphParameterValue>,
8623        required_index: Option<LogIndex>,
8624        max_rows: u32,
8625    ) -> Result<GraphQueryResult, NodeError> {
8626        self.ensure_ready()?;
8627        let graph = self.graph_materializer()?;
8628        let result = graph
8629            .query_read_only(
8630                statement,
8631                parameters,
8632                usize::try_from(max_rows).expect("u32 fits usize"),
8633                MAX_GRAPH_RESULT_BYTES,
8634                GRAPH_QUERY_TIMEOUT_MS,
8635            )
8636            .map_err(|error| self.map_graph_read_error(error))?;
8637        if required_index.is_some_and(|required| result.applied_index < required) {
8638            return Err(NodeError::Unavailable(format!(
8639                "local applied index {} has not reached {}",
8640                result.applied_index,
8641                required_index.expect("checked above")
8642            )));
8643        }
8644        Ok(result)
8645    }
8646
8647    #[cfg(feature = "kv")]
8648    fn get_kv_local(
8649        &self,
8650        key: &[u8],
8651        required_index: Option<LogIndex>,
8652    ) -> Result<KvReadResponse, NodeError> {
8653        self.ensure_ready()?;
8654        let kv = self.kv_materializer()?;
8655        let result = kv
8656            .get_with_tip(key)
8657            .map_err(|error| self.map_kv_read_error(error))?;
8658        let (value, tip) = result.into_parts();
8659        let applied_index = tip.applied_index();
8660        if required_index.is_some_and(|required| applied_index < required) {
8661            return Err(NodeError::Unavailable(format!(
8662                "local applied index {applied_index} has not reached {}",
8663                required_index.expect("checked above")
8664            )));
8665        }
8666        Ok(KvReadResponse {
8667            value,
8668            applied_index,
8669            hash: tip.applied_hash(),
8670        })
8671    }
8672
8673    #[cfg(feature = "kv")]
8674    fn scan_kv_range_local(
8675        &self,
8676        start: &[u8],
8677        end: Option<&[u8]>,
8678        limit: usize,
8679        cursor: Option<&[u8]>,
8680        required_index: Option<LogIndex>,
8681    ) -> Result<KvScanResult, NodeError> {
8682        self.ensure_ready()?;
8683        let kv = self.kv_materializer()?;
8684        let result = kv
8685            .scan_range(start, end, limit, cursor)
8686            .map_err(|error| self.map_kv_read_error(error))?;
8687        validate_kv_scan_required_index(&result, required_index)?;
8688        Ok(result)
8689    }
8690
8691    #[cfg(feature = "kv")]
8692    fn scan_kv_prefix_local(
8693        &self,
8694        prefix: &[u8],
8695        limit: usize,
8696        cursor: Option<&[u8]>,
8697        required_index: Option<LogIndex>,
8698    ) -> Result<KvScanResult, NodeError> {
8699        self.ensure_ready()?;
8700        let kv = self.kv_materializer()?;
8701        let result = kv
8702            .scan_prefix(prefix, limit, cursor)
8703            .map_err(|error| self.map_kv_read_error(error))?;
8704        validate_kv_scan_required_index(&result, required_index)?;
8705        Ok(result)
8706    }
8707
8708    #[cfg(feature = "sql")]
8709    fn query_sql_local(
8710        &self,
8711        statement: &SqlStatement,
8712        required_index: Option<LogIndex>,
8713        max_rows: u32,
8714    ) -> Result<SqlQueryResponse, NodeError> {
8715        self.ensure_ready()?;
8716        let sqlite = self.lock_sqlite()?;
8717        let (applied_index, hash) = sqlite
8718            .applied_tip_value()
8719            .map_err(|error| self.map_sqlite_error(error))?;
8720        if required_index.is_some_and(|required| applied_index < required) {
8721            return Err(NodeError::Unavailable(format!(
8722                "local applied index {applied_index} has not reached {}",
8723                required_index.expect("checked above")
8724            )));
8725        }
8726        let SqlQueryResult { columns, rows } = sqlite
8727            .query_sql(
8728                statement,
8729                usize::try_from(max_rows).expect("u32 fits usize"),
8730                MAX_SQL_RESULT_BYTES,
8731            )
8732            .map_err(|error| match error {
8733                rhiza_sql::Error::ResourceExhausted(message) => {
8734                    NodeError::ResourceExhausted(message)
8735                }
8736                other => NodeError::InvalidSqlStatement {
8737                    statement_index: 0,
8738                    message: other.to_string(),
8739                },
8740            })?;
8741        Ok(SqlQueryResponse {
8742            columns,
8743            rows,
8744            applied_index,
8745            hash,
8746        })
8747    }
8748
8749    fn establish_read_barrier(&self) -> Result<LogAnchor, NodeError> {
8750        let participant = self.read_barriers.join().map_err(|error| match error {
8751            NodeError::Invariant(_) => self.latch(error),
8752            other => other,
8753        })?;
8754        let Some(mut publication) = participant.publication() else {
8755            return participant.wait(&self.operation_cancelled);
8756        };
8757
8758        let result = (|| {
8759            publication.wait_turn(&self.operation_cancelled)?;
8760            // The public read path must not own this mutex before entering the
8761            // coalescer. The generation cutoff happens only after the round
8762            // leader owns commit, immediately before consensus begins.
8763            let _commit = self.lock_commit()?;
8764            publication.start(&self.operation_cancelled)?;
8765            self.ensure_ready()?;
8766            self.commit_read_barrier_locked()
8767        })();
8768        publication.publish(result.clone());
8769        result
8770    }
8771
8772    #[cfg(any(feature = "graph", feature = "kv"))]
8773    fn validate_read_barrier_before_snapshot(&self, anchor: LogAnchor) -> Result<(), NodeError> {
8774        {
8775            let _commit = self.lock_commit()?;
8776            self.ensure_ready()?;
8777            self.ensure_writes_active()?;
8778            self.validate_read_barrier_qlog_descendant_locked(anchor)?;
8779        }
8780        Ok(())
8781    }
8782
8783    #[cfg(any(feature = "graph", feature = "kv"))]
8784    fn validate_read_barrier_snapshot(
8785        &self,
8786        anchor: LogAnchor,
8787        observed: LogAnchor,
8788    ) -> Result<(), NodeError> {
8789        if observed.index() < anchor.index() {
8790            return Err(NodeError::Unavailable(format!(
8791                "read snapshot tip {} precedes read barrier {}",
8792                observed.index(),
8793                anchor.index()
8794            )));
8795        }
8796        if observed.index() == anchor.index() && observed.hash() != anchor.hash() {
8797            return Err(self.latch(NodeError::Invariant(
8798                "read snapshot tip hash differs from the read barrier anchor".into(),
8799            )));
8800        }
8801        Ok(())
8802    }
8803
8804    #[cfg(feature = "sql")]
8805    fn validate_read_barrier_descendant_locked(&self, anchor: LogAnchor) -> Result<(), NodeError> {
8806        let (applied_index, applied_hash) = self.ensure_materialized_tip()?;
8807        self.validate_read_barrier_descendant_from_tip(
8808            anchor,
8809            LogAnchor::new(applied_index, applied_hash),
8810            "materialized",
8811        )
8812    }
8813
8814    #[cfg(any(feature = "graph", feature = "kv"))]
8815    fn validate_read_barrier_qlog_descendant_locked(
8816        &self,
8817        anchor: LogAnchor,
8818    ) -> Result<(), NodeError> {
8819        let (qlog_index, qlog_hash) = self.durable_tip()?;
8820        self.validate_read_barrier_descendant_from_tip(
8821            anchor,
8822            LogAnchor::new(qlog_index, qlog_hash),
8823            "qlog",
8824        )
8825    }
8826
8827    fn validate_read_barrier_descendant_from_tip(
8828        &self,
8829        anchor: LogAnchor,
8830        tip: LogAnchor,
8831        tip_kind: &str,
8832    ) -> Result<(), NodeError> {
8833        if tip.index() < anchor.index() {
8834            return Err(self.latch(NodeError::Invariant(format!(
8835                "{tip_kind} tip {} precedes read barrier {}",
8836                tip.index(),
8837                anchor.index()
8838            ))));
8839        }
8840        if tip.index() == anchor.index() {
8841            if tip.hash() != anchor.hash() {
8842                return Err(self.latch(NodeError::Invariant(format!(
8843                    "{tip_kind} tip hash differs from the read barrier anchor"
8844                ))));
8845            }
8846            return Ok(());
8847        }
8848        if anchor.index() == 0 {
8849            if anchor.hash() == LogHash::ZERO {
8850                return Ok(());
8851            }
8852            return Err(self.latch(NodeError::Invariant(
8853                "genesis read barrier anchor has a non-zero hash".into(),
8854            )));
8855        }
8856
8857        let logical = self
8858            .log_store
8859            .logical_state()
8860            .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
8861        if let Some(compacted) = logical.anchor.as_ref().map(RecoveryAnchor::compacted) {
8862            if compacted.index() > anchor.index() {
8863                return Ok(());
8864            }
8865            if compacted.index() == anchor.index() {
8866                if compacted.hash() == anchor.hash() {
8867                    return Ok(());
8868                }
8869                return Err(self.latch(NodeError::Invariant(
8870                    "compacted qlog hash differs from the read barrier anchor".into(),
8871                )));
8872            }
8873        }
8874        let retained = self
8875            .log_store
8876            .read(anchor.index())
8877            .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?
8878            .ok_or_else(|| {
8879                self.latch(NodeError::Invariant(
8880                    "read barrier anchor is neither retained nor compacted".into(),
8881                ))
8882            })?;
8883        if retained.hash != anchor.hash() {
8884            return Err(self.latch(NodeError::Invariant(
8885                "retained qlog hash differs from the read barrier anchor".into(),
8886            )));
8887        }
8888        Ok(())
8889    }
8890
8891    fn commit_read_barrier_locked(&self) -> Result<LogAnchor, NodeError> {
8892        self.ensure_writes_active()?;
8893        let context_read_fence = self.consensus.supports_context_read_fence();
8894        loop {
8895            self.ensure_ready()?;
8896            let (last_index, last_hash) = self.ensure_materialized_tip()?;
8897            let slot = last_index.checked_add(1).ok_or_else(|| {
8898                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
8899            })?;
8900            let inspection = if context_read_fence {
8901                match self
8902                    .consensus
8903                    .inspect_context_read_fence_at(slot, last_hash)
8904                    .map_err(|error| self.map_consensus_error(error))?
8905                {
8906                    CertifiedDecisionInspection::Committed(certified) => {
8907                        DecisionInspection::Committed(certified.entry)
8908                    }
8909                    CertifiedDecisionInspection::Empty => DecisionInspection::Empty,
8910                    CertifiedDecisionInspection::Pending => DecisionInspection::Pending,
8911                    CertifiedDecisionInspection::Unavailable => DecisionInspection::Unavailable,
8912                }
8913            } else {
8914                self.consensus
8915                    .inspect_decision_at(slot, last_hash)
8916                    .map_err(|error| self.map_consensus_error(error))?
8917            };
8918            match inspection {
8919                DecisionInspection::Committed(entry) => {
8920                    self.persist_entry(&entry, slot, last_hash)?;
8921                }
8922                DecisionInspection::Empty if context_read_fence => {
8923                    // Configuration may have been sealed while the read-only
8924                    // quorum observation was in flight. Never return an anchor
8925                    // from a configuration that is no longer write-active.
8926                    self.ensure_writes_active()?;
8927                    return Ok(LogAnchor::new(last_index, last_hash));
8928                }
8929                DecisionInspection::Pending => {
8930                    let entry = self
8931                        .consensus
8932                        .propose_at_cancellable(
8933                            slot,
8934                            last_hash,
8935                            Command::new(CommandKind::ReadBarrier, Vec::new()),
8936                            &self.operation_cancelled,
8937                        )
8938                        .map_err(|error| self.map_consensus_error(error))?;
8939                    self.persist_entry(&entry, slot, last_hash)?;
8940                    // Pending may conceal a historical phase-2 Noop whose
8941                    // asynchronous proof was never installed. It cannot fence
8942                    // this read, so inspect the following slot before returning.
8943                }
8944                DecisionInspection::Empty => {
8945                    let entry = self
8946                        .consensus
8947                        .propose_at_cancellable(
8948                            slot,
8949                            last_hash,
8950                            Command::new(CommandKind::ReadBarrier, Vec::new()),
8951                            &self.operation_cancelled,
8952                        )
8953                        .map_err(|error| self.map_consensus_error(error))?;
8954                    let is_barrier =
8955                        entry.entry_type == EntryType::Noop && entry.payload.is_empty();
8956                    self.persist_entry(&entry, slot, last_hash)?;
8957                    if is_barrier {
8958                        return Ok(LogAnchor::new(entry.index, entry.hash));
8959                    }
8960                }
8961                DecisionInspection::Unavailable => {
8962                    return Err(NodeError::Unavailable(
8963                        "decision inspection did not reach quorum".into(),
8964                    ));
8965                }
8966            }
8967        }
8968    }
8969
8970    #[cfg(feature = "sql")]
8971    fn check_request(
8972        &self,
8973        request_id: &str,
8974        payload: &[u8],
8975    ) -> Result<Option<RequestOutcome>, NodeError> {
8976        let sqlite = self.lock_sqlite()?;
8977        sqlite
8978            .check_request(request_id, payload)
8979            .map_err(|error| self.map_sqlite_error(error))
8980    }
8981
8982    #[cfg(feature = "graph")]
8983    #[cfg_attr(
8984        all(not(feature = "sql"), not(feature = "kv")),
8985        allow(irrefutable_let_patterns)
8986    )]
8987    fn check_graph_request(
8988        &self,
8989        request_id: &str,
8990        payload: &[u8],
8991    ) -> Result<Option<GraphRequestRecord>, NodeError> {
8992        let materializer = self.lock_materializer()?;
8993        let Materializer::Graph(graph) = &*materializer else {
8994            return Err(NodeError::ExecutionProfileMismatch {
8995                expected: ExecutionProfile::Graph,
8996                actual: materializer.profile(),
8997            });
8998        };
8999        graph
9000            .check_request(request_id, payload)
9001            .map_err(|error| NodeError::InvalidRequest(error.to_string()))
9002    }
9003
9004    #[cfg(feature = "kv")]
9005    #[cfg_attr(
9006        all(not(feature = "sql"), not(feature = "graph")),
9007        allow(irrefutable_let_patterns)
9008    )]
9009    fn check_kv_request(
9010        &self,
9011        request_id: &str,
9012        payload: &[u8],
9013    ) -> Result<Option<KvRequestRecord>, NodeError> {
9014        let materializer = self.lock_materializer()?;
9015        let Materializer::Kv(kv) = &*materializer else {
9016            return Err(NodeError::ExecutionProfileMismatch {
9017                expected: ExecutionProfile::Kv,
9018                actual: materializer.profile(),
9019            });
9020        };
9021        kv.check_request(request_id, payload)
9022            .map_err(|error| NodeError::InvalidRequest(error.to_string()))
9023    }
9024
9025    fn ensure_materialized_tip(&self) -> Result<(LogIndex, LogHash), NodeError> {
9026        #[cfg(test)]
9027        self.materialized_tip_checks.fetch_add(1, Ordering::Relaxed);
9028        let (last_index, last_hash) = self.durable_tip()?;
9029        let materializer = self.lock_materializer()?;
9030        let applied_tip = materializer
9031            .applied_tip()
9032            .map_err(|error| self.latch(NodeError::Storage(error)))?;
9033        let applied_index = applied_tip.index();
9034        let applied_hash = applied_tip.hash();
9035        if (applied_index, applied_hash) != (last_index, last_hash) {
9036            return Err(self.latch(NodeError::Invariant(format!(
9037                "qlog tip {last_index}/{} differs from {} materializer tip {applied_index}/{}",
9038                last_hash.to_hex(),
9039                materializer.profile(),
9040                applied_hash.to_hex()
9041            ))));
9042        }
9043        Ok((last_index, last_hash))
9044    }
9045
9046    fn durable_tip(&self) -> Result<(LogIndex, LogHash), NodeError> {
9047        static_log_tip(&self.log_store).map_err(|error| self.latch(error))
9048    }
9049
9050    fn persist_entry(
9051        &self,
9052        entry: &LogEntry,
9053        expected_index: LogIndex,
9054        expected_prev_hash: LogHash,
9055    ) -> Result<Option<SqlCommandResult>, NodeError> {
9056        let configuration_state = self.configuration_state()?;
9057        validate_runtime_entry(
9058            &self.config,
9059            &configuration_state,
9060            entry,
9061            expected_index,
9062            expected_prev_hash,
9063        )
9064        .map_err(|error| self.latch(error))?;
9065        if matches!(
9066            self.config.execution_profile,
9067            ExecutionProfile::Sqlite | ExecutionProfile::Kv
9068        ) {
9069            // SQL/KV embed the complete entry in their durable materializer state. The file qlog
9070            // remains a buffered serving mirror and is rehydrated on startup.
9071            self.log_store
9072                .append_batch_buffered(std::slice::from_ref(entry))
9073                .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9074        } else {
9075            self.log_store
9076                .append(entry)
9077                .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9078        }
9079        self.lock_materializer()?
9080            .apply_entry(entry)
9081            .map_err(|error| self.latch(NodeError::Invariant(error)))
9082    }
9083
9084    #[cfg(feature = "sql")]
9085    fn persist_sql_entry_profiled<P: SqlWritePhaseProfile>(
9086        &self,
9087        entry: &LogEntry,
9088        expected_index: LogIndex,
9089        expected_prev_hash: LogHash,
9090        profile: &mut P,
9091    ) -> Result<Option<SqlCommandResult>, NodeError> {
9092        let configuration_state = self.configuration_state()?;
9093        validate_runtime_entry(
9094            &self.config,
9095            &configuration_state,
9096            entry,
9097            expected_index,
9098            expected_prev_hash,
9099        )
9100        .map_err(|error| self.latch(error))?;
9101
9102        let qlog_mark = profile.mark();
9103        let append_result = self
9104            .log_store
9105            .append_batch_buffered(std::slice::from_ref(entry))
9106            .map_err(|error| self.latch(NodeError::Storage(error.to_string())));
9107        profile.add_local_qlog_mirror_append(qlog_mark);
9108        append_result?;
9109
9110        let materializer_mark = profile.mark();
9111        let apply_result = self
9112            .lock_materializer()?
9113            .apply_entry(entry)
9114            .map_err(|error| self.latch(NodeError::Invariant(error)));
9115        profile.add_sql_materializer_apply(materializer_mark);
9116        apply_result
9117    }
9118
9119    fn require_execution_profile(&self, expected: ExecutionProfile) -> Result<(), NodeError> {
9120        if self.config.execution_profile == expected {
9121            Ok(())
9122        } else {
9123            Err(NodeError::ExecutionProfileMismatch {
9124                expected,
9125                actual: self.config.execution_profile,
9126            })
9127        }
9128    }
9129
9130    fn ensure_ready(&self) -> Result<(), NodeError> {
9131        if self.fatal.load(Ordering::Acquire) {
9132            return Err(NodeError::Fatal(
9133                self.fatal_reason()
9134                    .unwrap_or_else(|| "fatal state is latched".into()),
9135            ));
9136        }
9137        if !self.ready.load(Ordering::Acquire) {
9138            return Err(NodeError::Unavailable("runtime is not ready".into()));
9139        }
9140        if self.checkpointing.load(Ordering::Acquire) {
9141            return Err(NodeError::Unavailable(
9142                "runtime checkpoint transition is in progress".into(),
9143            ));
9144        }
9145        Ok(())
9146    }
9147
9148    fn ensure_writes_active(&self) -> Result<(), NodeError> {
9149        let state = self.configuration_state()?;
9150        if state.is_active() {
9151            Ok(())
9152        } else {
9153            Err(NodeError::ConfigurationTransition {
9154                state: Box::new(state),
9155            })
9156        }
9157    }
9158
9159    fn lock_commit(&self) -> Result<MutexGuard<'_, ()>, NodeError> {
9160        self.commit
9161            .lock()
9162            .map_err(|_| self.latch(NodeError::Invariant("commit mutex is poisoned".into())))
9163    }
9164
9165    fn lock_materializer(&self) -> Result<MutexGuard<'_, Materializer>, NodeError> {
9166        self.materializer.lock().map_err(|_| {
9167            self.latch(NodeError::Invariant(
9168                "materializer mutex is poisoned".into(),
9169            ))
9170        })
9171    }
9172
9173    #[cfg(feature = "graph")]
9174    #[cfg_attr(
9175        all(not(feature = "sql"), not(feature = "kv")),
9176        allow(irrefutable_let_patterns)
9177    )]
9178    fn graph_materializer(&self) -> Result<Arc<LadybugStateMachine>, NodeError> {
9179        let materializer = self.lock_materializer()?;
9180        let Materializer::Graph(graph) = &*materializer else {
9181            return Err(NodeError::ExecutionProfileMismatch {
9182                expected: ExecutionProfile::Graph,
9183                actual: materializer.profile(),
9184            });
9185        };
9186        Ok(Arc::clone(graph))
9187    }
9188
9189    #[cfg(feature = "kv")]
9190    #[cfg_attr(
9191        all(not(feature = "sql"), not(feature = "graph")),
9192        allow(irrefutable_let_patterns)
9193    )]
9194    fn kv_materializer(&self) -> Result<Arc<RedbStateMachine>, NodeError> {
9195        let materializer = self.lock_materializer()?;
9196        let Materializer::Kv(kv) = &*materializer else {
9197            return Err(NodeError::ExecutionProfileMismatch {
9198                expected: ExecutionProfile::Kv,
9199                actual: materializer.profile(),
9200            });
9201        };
9202        Ok(Arc::clone(kv))
9203    }
9204
9205    #[cfg(feature = "sql")]
9206    fn lock_sqlite(&self) -> Result<SqlMaterializerGuard<'_>, NodeError> {
9207        let guard = self.lock_materializer()?;
9208        if !matches!(&*guard, Materializer::Sql(_)) {
9209            return Err(NodeError::ExecutionProfileMismatch {
9210                expected: ExecutionProfile::Sqlite,
9211                actual: guard.profile(),
9212            });
9213        }
9214        Ok(SqlMaterializerGuard(guard))
9215    }
9216
9217    #[cfg(feature = "sql")]
9218    fn map_sqlite_error(&self, error: rhiza_sql::Error) -> NodeError {
9219        match error {
9220            rhiza_sql::Error::RequestConflict(conflict) => NodeError::RequestConflict(conflict),
9221            rhiza_sql::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9222            rhiza_sql::Error::InvalidCommand(message)
9223            | rhiza_sql::Error::IdentityMismatch(message)
9224            | rhiza_sql::Error::InvalidEntry(message)
9225            | rhiza_sql::Error::InvalidSnapshot(message) => {
9226                self.latch(NodeError::Invariant(message))
9227            }
9228            other => self.latch(NodeError::Storage(other.to_string())),
9229        }
9230    }
9231
9232    #[cfg(feature = "graph")]
9233    fn map_graph_read_error(&self, error: rhiza_graph::Error) -> NodeError {
9234        match error {
9235            rhiza_graph::Error::InvalidCommand(_) => NodeError::InvalidRequest(error.to_string()),
9236            rhiza_graph::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9237            rhiza_graph::Error::Ladybug(_) | rhiza_graph::Error::Io(_) => {
9238                self.latch(NodeError::Storage(error.to_string()))
9239            }
9240            rhiza_graph::Error::Closed
9241            | rhiza_graph::Error::Codec(_)
9242            | rhiza_graph::Error::InvalidEntry(_)
9243            | rhiza_graph::Error::IdentityMismatch(_)
9244            | rhiza_graph::Error::RequestConflict { .. }
9245            | rhiza_graph::Error::InvalidSnapshot(_) => {
9246                self.latch(NodeError::Invariant(error.to_string()))
9247            }
9248        }
9249    }
9250
9251    #[cfg(feature = "kv")]
9252    fn map_kv_read_error(&self, error: rhiza_kv::Error) -> NodeError {
9253        match error {
9254            rhiza_kv::Error::InvalidCommand(_) | rhiza_kv::Error::InvalidQuery(_) => {
9255                NodeError::InvalidRequest(error.to_string())
9256            }
9257            rhiza_kv::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9258            rhiza_kv::Error::Database(_) | rhiza_kv::Error::Io(_) => {
9259                self.latch(NodeError::Storage(error.to_string()))
9260            }
9261            rhiza_kv::Error::Codec(_)
9262            | rhiza_kv::Error::InvalidEntry(_)
9263            | rhiza_kv::Error::PartialInitialization
9264            | rhiza_kv::Error::RequestConflict { .. }
9265            | rhiza_kv::Error::InvalidSnapshot(_) => {
9266                self.latch(NodeError::Invariant(error.to_string()))
9267            }
9268        }
9269    }
9270
9271    fn map_consensus_error(&self, error: rhiza_quepaxa::Error) -> NodeError {
9272        match error {
9273            rhiza_quepaxa::Error::NoQuorum
9274            | rhiza_quepaxa::Error::ProposeFailed
9275            | rhiza_quepaxa::Error::CommandUnavailable
9276            | rhiza_quepaxa::Error::Cancelled
9277            | rhiza_quepaxa::Error::Io(_) => NodeError::Unavailable(error.to_string()),
9278            rhiza_quepaxa::Error::ConflictingCertificates
9279            | rhiza_quepaxa::Error::ChainConflict { .. } => {
9280                self.latch(NodeError::Reconciliation(error.to_string()))
9281            }
9282            other => self.latch(NodeError::Invariant(other.to_string())),
9283        }
9284    }
9285
9286    fn latch(&self, error: NodeError) -> NodeError {
9287        self.ready.store(false, Ordering::Release);
9288        if !self.fatal.swap(true, Ordering::AcqRel) {
9289            eprintln!(
9290                "node runtime entered fatal state: {}",
9291                escaped_error_detail(&error)
9292            );
9293            if let Ok(mut reason) = self.fatal_reason.lock() {
9294                *reason = Some(error.to_string());
9295            }
9296        }
9297        error
9298    }
9299}
9300
9301pub fn rehydrate_recorder_after_checkpoint(
9302    runtime: &NodeRuntime,
9303    recorder: &RecorderFileStore,
9304    checkpoint_index: LogIndex,
9305) -> Result<(), NodeError> {
9306    if let Some(anchor) = runtime
9307        .log_store()
9308        .logical_state()
9309        .map_err(|error| NodeError::Storage(error.to_string()))?
9310        .anchor
9311    {
9312        if checkpoint_index < anchor.compacted().index() {
9313            return Err(NodeError::SnapshotRequired(Box::new(anchor)));
9314        }
9315    }
9316    let applied_index = runtime.applied_index()?;
9317    if checkpoint_index > applied_index {
9318        return Err(NodeError::Reconciliation(format!(
9319            "checkpoint tip {checkpoint_index} is ahead of local applied index {applied_index}"
9320        )));
9321    }
9322
9323    for index in checkpoint_index.saturating_add(1)..=applied_index {
9324        let entry = runtime
9325            .log_store()
9326            .read(index)
9327            .map_err(|error| NodeError::Storage(error.to_string()))?
9328            .ok_or_else(|| {
9329                NodeError::Reconciliation(format!(
9330                    "qlog entry {index} is missing during recorder rehydration"
9331                ))
9332            })?;
9333        let certified = match runtime
9334            .consensus()
9335            .inspect_certified_decision_at(index, entry.prev_hash)
9336            .map_err(startup_consensus_error)?
9337        {
9338            CertifiedDecisionInspection::Committed(certified) => certified,
9339            CertifiedDecisionInspection::Empty => {
9340                return Err(NodeError::Reconciliation(format!(
9341                    "qlog entry {index} has no recorder decision certificate"
9342                )))
9343            }
9344            CertifiedDecisionInspection::Pending => {
9345                return Err(NodeError::Reconciliation(format!(
9346                    "qlog entry {index} has only a pending recorder decision"
9347                )))
9348            }
9349            CertifiedDecisionInspection::Unavailable => {
9350                return Err(NodeError::Unavailable(format!(
9351                    "recorder decision certificate is unavailable at qlog index {index}"
9352                )))
9353            }
9354        };
9355        if certified.entry != entry {
9356            return Err(NodeError::Reconciliation(format!(
9357                "recorder decision certificate differs from qlog entry {index}"
9358            )));
9359        }
9360        let command = StoredCommand::new(entry.entry_type, entry.payload.clone());
9361        recorder
9362            .store_command(command.hash(), command)
9363            .map_err(|error| {
9364                NodeError::Reconciliation(format!(
9365                    "cannot restore recorder command at qlog index {index}: {error}"
9366                ))
9367            })?;
9368        let proof = certified.proof.clone();
9369        recorder
9370            .install_decision_proof_record(proof, runtime.consensus().membership())
9371            .map_err(|error| {
9372                NodeError::Reconciliation(format!(
9373                    "cannot install recorder decision at qlog index {index}: {error}"
9374                ))
9375            })?;
9376    }
9377    Ok(())
9378}
9379
9380#[cfg(test)]
9381mod tests {
9382    use axum::http::HeaderValue;
9383
9384    use rhiza_core::{
9385        Command, CommandKind, EntryType, ErrorCategory, ErrorClassification, ExecutionProfile,
9386        LogAnchor, LogHash, RecoveryAnchor, SnapshotIdentity, StoredCommand,
9387    };
9388    #[cfg(feature = "graph")]
9389    use rhiza_graph::{GraphCommandV1, GraphValueV1};
9390    #[cfg(feature = "kv")]
9391    use rhiza_kv::KvCommandV1;
9392    use rhiza_log::LogStore as _;
9393    use rhiza_quepaxa::{
9394        AcceptedValue, Membership, Proposal, ProposalPriority, RecordRequest, ThreeNodeConsensus,
9395    };
9396    use std::sync::{
9397        atomic::{AtomicBool, AtomicUsize, Ordering},
9398        mpsc, Arc, Barrier,
9399    };
9400
9401    #[cfg(any(feature = "sql", feature = "graph", feature = "kv"))]
9402    use super::node_error_response;
9403    #[cfg(feature = "graph")]
9404    use super::with_graph_client_permit;
9405    use super::ReadBarrierRounds;
9406    use super::{
9407        client_authenticated, next_sync_flush_retry, run_read_operation, sql_query_http_response,
9408        valid_recorder_record, Duration, HeaderMap, NodeError, ReadConsistency, SqlCommand,
9409        SqlQueryResponse, SqlStatement, SqlValue, SqlWriteProfiler, MAX_COMMAND_BYTES,
9410        MAX_SQL_RESPONSE_BYTES, PROTOCOL_VERSION, QWAL_V3_MAGIC, SYNC_FLUSH_RETRY_INITIAL,
9411        VERSION_HEADER,
9412    };
9413    use super::{ConfigError, NodeConfig, NodeRuntime, NodeService};
9414
9415    #[test]
9416    fn embedded_config_accepts_matching_canonical_profile_ids() {
9417        for (cluster_id, profile) in [
9418            ("rhiza:graph:embedded", ExecutionProfile::Graph),
9419            ("rhiza:kv:embedded", ExecutionProfile::Kv),
9420        ] {
9421            let config = NodeConfig::new_embedded(
9422                cluster_id,
9423                "n1",
9424                std::env::temp_dir().join(profile.as_str()),
9425                1,
9426                1,
9427                ["n1", "n2", "n3"],
9428            )
9429            .unwrap()
9430            .with_execution_profile(profile)
9431            .unwrap();
9432
9433            assert_eq!(config.cluster_id(), cluster_id);
9434            assert_eq!(config.logical_cluster_id(), "embedded");
9435        }
9436    }
9437
9438    #[test]
9439    fn embedded_config_rejects_conflicting_canonical_profile_and_preserves_logical_ids() {
9440        let conflicting = NodeConfig::new_embedded(
9441            "rhiza:graph:embedded",
9442            "n1",
9443            std::env::temp_dir().join("conflicting-profile"),
9444            1,
9445            1,
9446            ["n1", "n2", "n3"],
9447        )
9448        .unwrap()
9449        .with_execution_profile(ExecutionProfile::Sqlite)
9450        .unwrap_err();
9451        assert!(matches!(
9452            conflicting,
9453            ConfigError::ClusterIdProfileMismatch {
9454                expected: ExecutionProfile::Sqlite,
9455                actual: ExecutionProfile::Graph,
9456            }
9457        ));
9458
9459        let logical = NodeConfig::new_embedded(
9460            "embedded",
9461            "n1",
9462            std::env::temp_dir().join("logical-profile"),
9463            1,
9464            1,
9465            ["n1", "n2", "n3"],
9466        )
9467        .unwrap();
9468        assert_eq!(logical.cluster_id(), "rhiza:sql:embedded");
9469        assert_eq!(logical.logical_cluster_id(), "embedded");
9470    }
9471
9472    #[test]
9473    fn node_error_classification_reports_observable_retry_semantics() {
9474        let cases = [
9475            (
9476                NodeError::InvalidRequest("missing key".into()),
9477                "invalid_request",
9478                ErrorCategory::InvalidRequest,
9479                false,
9480            ),
9481            (
9482                NodeError::PreconditionFailed("stale version".into()),
9483                "precondition_failed",
9484                ErrorCategory::Conflict,
9485                false,
9486            ),
9487            (
9488                NodeError::Unavailable("no quorum".into()),
9489                "unavailable",
9490                ErrorCategory::Unavailable,
9491                true,
9492            ),
9493            (
9494                NodeError::ResourceExhausted("result too large".into()),
9495                "resource_exhausted",
9496                ErrorCategory::ResourceExhausted,
9497                true,
9498            ),
9499            (
9500                NodeError::Invariant("invalid log".into()),
9501                "invariant_violation",
9502                ErrorCategory::Internal,
9503                false,
9504            ),
9505        ];
9506
9507        for (error, code, category, retryable) in cases {
9508            let classification = error.classification();
9509
9510            assert_eq!(classification.code(), code);
9511            assert_eq!(classification.category(), category);
9512            assert_eq!(classification.retryable(), retryable);
9513        }
9514    }
9515
9516    #[cfg(feature = "sql")]
9517    #[test]
9518    fn sql_batch_error_classification_preserves_statement_index_category() {
9519        let error = NodeError::InvalidSqlStatement {
9520            statement_index: 3,
9521            message: "syntax error".into(),
9522        };
9523
9524        let classification = error.classification();
9525
9526        assert_eq!(classification.code(), "invalid_request");
9527        assert_eq!(classification.category(), ErrorCategory::InvalidRequest);
9528        assert!(!classification.retryable());
9529    }
9530
9531    #[cfg(feature = "sql")]
9532    #[tokio::test]
9533    async fn node_error_http_response_preserves_v1_contract() {
9534        let snapshot = RecoveryAnchor::new(
9535            "cluster",
9536            1,
9537            1,
9538            1,
9539            LogAnchor::new(1, LogHash::ZERO),
9540            SnapshotIdentity::new("snapshot", LogHash::ZERO, 0),
9541        );
9542        let cases = vec![
9543            (
9544                NodeError::InvalidSqlStatement {
9545                    statement_index: 3,
9546                    message: "syntax error".into(),
9547                },
9548                axum::http::StatusCode::BAD_REQUEST,
9549                "invalid_request",
9550                false,
9551                Some(3),
9552            ),
9553            (
9554                NodeError::PreconditionFailed("stale version".into()),
9555                axum::http::StatusCode::CONFLICT,
9556                "precondition_failed",
9557                false,
9558                None,
9559            ),
9560            (
9561                NodeError::SnapshotRequired(Box::new(snapshot)),
9562                axum::http::StatusCode::SERVICE_UNAVAILABLE,
9563                "snapshot_required",
9564                false,
9565                None,
9566            ),
9567            (
9568                NodeError::Storage("disk failed".into()),
9569                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
9570                "storage_error",
9571                false,
9572                None,
9573            ),
9574        ];
9575
9576        for (node_error, status, code, retryable, statement_index) in cases {
9577            let response = node_error_response(node_error);
9578            assert_eq!(response.status(), status);
9579            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9580                .await
9581                .unwrap();
9582            let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
9583            let error: super::ClientErrorResponse = serde_json::from_value(value.clone()).unwrap();
9584            assert_eq!(error.code, code);
9585            assert_eq!(error.retryable, retryable);
9586            assert_eq!(error.statement_index, statement_index);
9587            assert!(value.get("category").is_none());
9588            assert!(matches!(
9589                error.message.as_str(),
9590                "request could not be processed"
9591                    | "request conflicts with current state"
9592                    | "service is temporarily unavailable"
9593                    | "internal server error"
9594            ));
9595        }
9596    }
9597
9598    #[cfg(feature = "sql")]
9599    #[tokio::test]
9600    async fn node_error_http_response_hides_display_details() {
9601        let detail = "/srv/rhiza/private/consensus/log: checksum mismatch";
9602        let cases = [
9603            (
9604                NodeError::Storage(detail.into()),
9605                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
9606                "storage_error",
9607                None,
9608                "internal server error",
9609            ),
9610            (
9611                NodeError::Reconciliation(detail.into()),
9612                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
9613                "reconciliation_error",
9614                None,
9615                "internal server error",
9616            ),
9617            (
9618                NodeError::InvalidSqlStatement {
9619                    statement_index: 7,
9620                    message: detail.into(),
9621                },
9622                axum::http::StatusCode::BAD_REQUEST,
9623                "invalid_request",
9624                Some(7),
9625                "request could not be processed",
9626            ),
9627        ];
9628
9629        for (node_error, status, code, statement_index, message) in cases {
9630            let response = node_error_response(node_error);
9631            assert_eq!(response.status(), status);
9632            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9633                .await
9634                .unwrap();
9635            let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
9636            assert_eq!(error.code, code);
9637            assert_eq!(error.statement_index, statement_index);
9638            assert_eq!(error.message, message);
9639            assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
9640        }
9641    }
9642
9643    #[tokio::test]
9644    async fn client_json_error_response_uses_a_stable_message() {
9645        for (status, code) in [
9646            (axum::http::StatusCode::BAD_REQUEST, "invalid_json"),
9647            (
9648                axum::http::StatusCode::PAYLOAD_TOO_LARGE,
9649                "payload_too_large",
9650            ),
9651        ] {
9652            let response = super::client_json_error_response(status);
9653
9654            assert_eq!(response.status(), status);
9655            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9656                .await
9657                .unwrap();
9658            let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
9659            assert_eq!(error.code, code);
9660            assert!(!error.retryable);
9661            assert_eq!(error.message, "request body is invalid");
9662            assert_eq!(error.statement_index, None);
9663        }
9664    }
9665
9666    #[tokio::test]
9667    async fn client_task_error_hides_join_error_details() {
9668        let detail = "private task detail\nforged log entry";
9669        let error = tokio::spawn(async move { panic!("{detail}") })
9670            .await
9671            .unwrap_err();
9672        let response = super::client_task_error(error);
9673
9674        assert_eq!(
9675            response.status(),
9676            axum::http::StatusCode::INTERNAL_SERVER_ERROR
9677        );
9678        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9679            .await
9680            .unwrap();
9681        let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
9682        assert_eq!(error.code, "task_failed");
9683        assert!(!error.retryable);
9684        assert_eq!(error.message, "request task failed");
9685        assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
9686    }
9687
9688    #[cfg(feature = "sql")]
9689    #[tokio::test]
9690    async fn fatal_readiness_response_hides_fatal_reason() {
9691        let (_dir, runtime) = sql_test_runtime();
9692        let detail = "/srv/rhiza/private\nforged log entry";
9693        runtime.latch(NodeError::Storage(detail.into()));
9694        let response = super::runtime_readiness_response(&runtime).unwrap();
9695
9696        assert_eq!(
9697            response.status(),
9698            axum::http::StatusCode::INTERNAL_SERVER_ERROR
9699        );
9700        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9701            .await
9702            .unwrap();
9703        let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
9704        assert_eq!(error.code, "fatal");
9705        assert!(!error.retryable);
9706        assert_eq!(error.message, "node is fatally unavailable");
9707        assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
9708    }
9709
9710    #[test]
9711    fn escaped_error_detail_escapes_control_characters() {
9712        let detail = "checksum mismatch\nforged entry\r\u{1b}[2J";
9713
9714        assert_eq!(
9715            super::escaped_error_detail(&detail),
9716            r"checksum mismatch\nforged entry\r\u{1b}[2J"
9717        );
9718    }
9719
9720    #[test]
9721    fn escaped_error_detail_bounds_escape_expansion() {
9722        let detail = "\n".repeat(super::MAX_ESCAPED_ERROR_DETAIL_BYTES / 2 + 1);
9723        let escaped = super::escaped_error_detail(&detail);
9724
9725        assert!(
9726            escaped.len() <= super::MAX_ESCAPED_ERROR_DETAIL_BYTES,
9727            "escaped detail must stay within the log budget"
9728        );
9729        assert!(
9730            escaped.ends_with(super::ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER),
9731            "truncated details must be explicit"
9732        );
9733        assert!(!escaped.contains('\n'));
9734    }
9735
9736    #[tokio::test]
9737    async fn client_error_responses_preserve_payload_and_authentication_wire_codes() {
9738        for (status, code, retryable, category) in [
9739            (
9740                axum::http::StatusCode::PAYLOAD_TOO_LARGE,
9741                "payload_too_large",
9742                false,
9743                ErrorCategory::ResourceExhausted,
9744            ),
9745            (
9746                axum::http::StatusCode::UNAUTHORIZED,
9747                "unauthorized",
9748                false,
9749                ErrorCategory::Authentication,
9750            ),
9751        ] {
9752            let response =
9753                super::client_error_response(status, code, retryable, "request failed", None);
9754            assert_eq!(response.status(), status);
9755            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9756                .await
9757                .unwrap();
9758            let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
9759            let error: super::ClientErrorResponse = serde_json::from_value(value.clone()).unwrap();
9760            assert_eq!(error.code, code);
9761            assert_eq!(error.retryable, retryable);
9762            assert!(value.get("category").is_none());
9763            assert_eq!(
9764                ErrorClassification::from_server_code(code, retryable).category(),
9765                category
9766            );
9767        }
9768    }
9769
9770    #[test]
9771    fn concurrent_read_barriers_registered_before_cutoff_share_one_generation() {
9772        let rounds = ReadBarrierRounds::new(Duration::ZERO);
9773        let cancelled = AtomicBool::new(false);
9774        let participants = (0..4).map(|_| rounds.join().unwrap()).collect::<Vec<_>>();
9775        let generation = participants[0].generation();
9776        assert!(participants[0].is_leader());
9777        assert!(participants[1..]
9778            .iter()
9779            .all(|participant| !participant.is_leader() && participant.generation() == generation));
9780
9781        let calls = AtomicUsize::new(0);
9782        let mut publication = participants[0].publication().unwrap();
9783        publication.wait_turn(&cancelled).unwrap();
9784        publication.start(&cancelled).unwrap();
9785        let anchor = LogAnchor::new(7, LogHash::digest(&[b"shared-barrier"]));
9786        calls.fetch_add(1, Ordering::Relaxed);
9787        publication.publish(Ok(anchor));
9788
9789        assert_eq!(calls.load(Ordering::Relaxed), 1);
9790        for participant in &participants[1..] {
9791            assert_eq!(participant.wait(&cancelled).unwrap(), anchor);
9792        }
9793    }
9794
9795    #[test]
9796    fn read_barrier_arriving_after_running_cutoff_uses_next_generation() {
9797        let rounds = Arc::new(ReadBarrierRounds::new(Duration::ZERO));
9798        let cancelled = Arc::new(AtomicBool::new(false));
9799        let first = rounds.join().unwrap();
9800        let first_generation = first.generation();
9801        let (running_tx, running_rx) = mpsc::channel();
9802        let (release_tx, release_rx) = mpsc::channel();
9803        let first_cancelled = Arc::clone(&cancelled);
9804        let first_worker = std::thread::spawn(move || {
9805            let mut publication = first.publication().unwrap();
9806            publication.wait_turn(&first_cancelled).unwrap();
9807            publication.start(&first_cancelled).unwrap();
9808            running_tx.send(()).unwrap();
9809            release_rx.recv().unwrap();
9810            let anchor = LogAnchor::new(1, LogHash::digest(&[b"first"]));
9811            publication.publish(Ok(anchor));
9812            anchor
9813        });
9814        running_rx.recv().unwrap();
9815
9816        let late = rounds.join().unwrap();
9817        assert!(late.is_leader());
9818        assert_eq!(late.generation(), first_generation + 1);
9819        release_tx.send(()).unwrap();
9820        assert_eq!(first_worker.join().unwrap().index(), 1);
9821
9822        let mut publication = late.publication().unwrap();
9823        publication.wait_turn(&cancelled).unwrap();
9824        publication.start(&cancelled).unwrap();
9825        let second = LogAnchor::new(2, LogHash::digest(&[b"second"]));
9826        publication.publish(Ok(second));
9827        assert_eq!(late.wait(&cancelled).unwrap(), second);
9828    }
9829
9830    #[test]
9831    fn completed_read_barrier_is_not_reused_and_predecessor_failure_retries_independently() {
9832        let rounds = ReadBarrierRounds::new(Duration::ZERO);
9833        let cancelled = AtomicBool::new(false);
9834        let failed = rounds.join().unwrap();
9835        let failed_generation = failed.generation();
9836        let mut publication = failed.publication().unwrap();
9837        publication.wait_turn(&cancelled).unwrap();
9838        publication.start(&cancelled).unwrap();
9839        publication.publish(Err(NodeError::Unavailable("no quorum".into())));
9840        assert!(matches!(
9841            failed.wait(&cancelled),
9842            Err(NodeError::Unavailable(_))
9843        ));
9844
9845        let retry = rounds.join().unwrap();
9846        assert!(retry.is_leader());
9847        assert_eq!(retry.generation(), failed_generation + 1);
9848        let mut publication = retry.publication().unwrap();
9849        publication.wait_turn(&cancelled).unwrap();
9850        publication.start(&cancelled).unwrap();
9851        let anchor = LogAnchor::new(1, LogHash::digest(&[b"retry"]));
9852        publication.publish(Ok(anchor));
9853        assert_eq!(retry.wait(&cancelled).unwrap(), anchor);
9854
9855        let later = rounds.join().unwrap();
9856        assert!(later.is_leader());
9857        assert_eq!(later.generation(), retry.generation() + 1);
9858    }
9859
9860    #[test]
9861    fn read_barrier_leader_drop_and_global_cancel_wake_waiters() {
9862        let rounds = Arc::new(ReadBarrierRounds::new(Duration::ZERO));
9863        let cancelled = Arc::new(AtomicBool::new(false));
9864        let abandoned = rounds.join().unwrap();
9865        let follower = rounds.join().unwrap();
9866        drop(abandoned.publication().unwrap());
9867        assert!(matches!(
9868            follower.wait(&cancelled),
9869            Err(NodeError::Unavailable(_))
9870        ));
9871
9872        let leader = rounds.join().unwrap();
9873        let waiting = rounds.join().unwrap();
9874        let waiting_cancelled = Arc::clone(&cancelled);
9875        let waiter = std::thread::spawn(move || waiting.wait(&waiting_cancelled));
9876        cancelled.store(true, Ordering::Release);
9877        rounds.cancel_waiters();
9878        assert!(matches!(
9879            waiter.join().unwrap(),
9880            Err(NodeError::Unavailable(_))
9881        ));
9882        drop(leader.publication().unwrap());
9883    }
9884
9885    #[test]
9886    fn sql_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
9887        let (_dir, mut runtime) = sql_test_runtime();
9888        runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
9889        let runtime = Arc::new(runtime);
9890        let start = Arc::new(Barrier::new(4));
9891        let workers = (0..4)
9892            .map(|_| {
9893                let runtime = Arc::clone(&runtime);
9894                let start = Arc::clone(&start);
9895                std::thread::spawn(move || {
9896                    start.wait();
9897                    runtime.read("missing", ReadConsistency::ReadBarrier)
9898                })
9899            })
9900            .collect::<Vec<_>>();
9901
9902        let responses = workers
9903            .into_iter()
9904            .map(|worker| worker.join().unwrap().unwrap())
9905            .collect::<Vec<_>>();
9906        assert!(responses
9907            .iter()
9908            .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
9909        assert_eq!(runtime.log_store().last_index().unwrap(), None);
9910    }
9911
9912    #[test]
9913    fn read_barrier_anchor_remains_valid_when_materialized_tip_advances() {
9914        let (_dir, runtime) = sql_test_runtime();
9915        let anchor = runtime.establish_read_barrier().unwrap();
9916        let write = runtime.write("request-1", "alpha", "one").unwrap();
9917        assert!(write.applied_index > anchor.index());
9918
9919        let _commit = runtime.lock_commit().unwrap();
9920        runtime.ensure_ready().unwrap();
9921        runtime.ensure_writes_active().unwrap();
9922        runtime
9923            .validate_read_barrier_descendant_locked(anchor)
9924            .unwrap();
9925        let read = runtime.read_local("alpha", Some(anchor.index())).unwrap();
9926
9927        assert_eq!(read.value.as_deref(), Some("one"));
9928        assert_eq!(read.applied_index, write.applied_index);
9929        assert_eq!(read.hash, write.hash);
9930    }
9931
9932    #[cfg(feature = "graph")]
9933    #[test]
9934    fn graph_read_barrier_checks_materialized_tip_once_before_snapshot() {
9935        let (_dir, runtime) = graph_test_runtime();
9936
9937        let response = runtime
9938            .get_graph_document("missing", ReadConsistency::ReadBarrier)
9939            .unwrap();
9940
9941        assert_eq!(response.applied_index, 0);
9942        assert_eq!(response.hash, LogHash::ZERO);
9943        assert_eq!(runtime.materialized_tip_checks.load(Ordering::Relaxed), 1);
9944    }
9945
9946    #[cfg(feature = "graph")]
9947    #[test]
9948    fn graph_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
9949        let (_dir, mut runtime) = graph_test_runtime();
9950        runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
9951        let runtime = Arc::new(runtime);
9952        let start = Arc::new(Barrier::new(4));
9953        let workers = (0..4)
9954            .map(|_| {
9955                let runtime = Arc::clone(&runtime);
9956                let start = Arc::clone(&start);
9957                std::thread::spawn(move || {
9958                    start.wait();
9959                    runtime.get_graph_document("missing", ReadConsistency::ReadBarrier)
9960                })
9961            })
9962            .collect::<Vec<_>>();
9963
9964        let responses = workers
9965            .into_iter()
9966            .map(|worker| worker.join().unwrap().unwrap())
9967            .collect::<Vec<_>>();
9968        assert!(responses
9969            .iter()
9970            .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
9971        assert_eq!(runtime.log_store().last_index().unwrap(), None);
9972    }
9973
9974    #[cfg(feature = "graph")]
9975    #[test]
9976    fn graph_read_barrier_releases_commit_lock_before_backend_snapshot() {
9977        let (_dir, mut runtime) = graph_test_runtime();
9978        let initial = runtime
9979            .mutate_graph(
9980                GraphCommandV1::put_document(
9981                    "request-1",
9982                    "document-1",
9983                    GraphValueV1::String("one".into()),
9984                )
9985                .unwrap(),
9986            )
9987            .unwrap();
9988        let entered = Arc::new(Barrier::new(2));
9989        let release = Arc::new(Barrier::new(2));
9990        runtime.read_barrier_before_snapshot_hook = Some(Arc::new({
9991            let entered = Arc::clone(&entered);
9992            let release = Arc::clone(&release);
9993            move || {
9994                entered.wait();
9995                release.wait();
9996            }
9997        }));
9998        let runtime = Arc::new(runtime);
9999        let reader = {
10000            let runtime = Arc::clone(&runtime);
10001            std::thread::spawn(move || {
10002                runtime.get_graph_document("document-1", ReadConsistency::ReadBarrier)
10003            })
10004        };
10005        entered.wait();
10006        let (advanced_tx, advanced_rx) = mpsc::channel();
10007        let writer = {
10008            let runtime = Arc::clone(&runtime);
10009            std::thread::spawn(move || {
10010                let outcome = runtime
10011                    .mutate_graph(
10012                        GraphCommandV1::put_document(
10013                            "request-2",
10014                            "document-1",
10015                            GraphValueV1::String("two".into()),
10016                        )
10017                        .unwrap(),
10018                    )
10019                    .unwrap();
10020                advanced_tx
10021                    .send((outcome.applied_index(), outcome.hash()))
10022                    .unwrap();
10023                outcome
10024            })
10025        };
10026
10027        let advanced_before_snapshot = advanced_rx.recv_timeout(Duration::from_secs(2));
10028        release.wait();
10029        let written = writer.join().unwrap();
10030        let read = reader.join().unwrap().unwrap();
10031
10032        assert!(
10033            advanced_before_snapshot.is_ok(),
10034            "graph write must advance while the read is paused before its backend snapshot"
10035        );
10036        assert!(read.applied_index >= initial.applied_index());
10037        if read.applied_index == initial.applied_index() {
10038            assert_eq!(read.hash, initial.hash());
10039        }
10040        assert_eq!(read.applied_index, written.applied_index());
10041        assert_eq!(read.hash, written.hash());
10042    }
10043
10044    #[cfg(feature = "graph")]
10045    #[test]
10046    fn read_barrier_rejects_same_index_snapshot_with_different_hash() {
10047        let (_dir, runtime) = graph_test_runtime();
10048        let anchor = LogAnchor::new(7, LogHash::digest(&[b"barrier-anchor"]));
10049        let observed = LogAnchor::new(7, LogHash::digest(&[b"divergent-snapshot"]));
10050
10051        assert!(matches!(
10052            runtime.validate_read_barrier_snapshot(anchor, observed),
10053            Err(NodeError::Invariant(message))
10054                if message.contains("snapshot tip hash differs")
10055        ));
10056        assert!(runtime.is_fatal());
10057    }
10058
10059    #[cfg(feature = "kv")]
10060    #[test]
10061    fn kv_read_barrier_checks_materialized_tip_once_before_snapshot() {
10062        let (_dir, runtime) = kv_test_runtime();
10063
10064        let response = runtime
10065            .get_kv(b"missing", ReadConsistency::ReadBarrier)
10066            .unwrap();
10067
10068        assert_eq!(response.applied_index, 0);
10069        assert_eq!(response.hash, LogHash::ZERO);
10070        assert_eq!(runtime.materialized_tip_checks.load(Ordering::Relaxed), 1);
10071    }
10072
10073    #[cfg(feature = "kv")]
10074    #[test]
10075    fn kv_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
10076        let (_dir, mut runtime) = kv_test_runtime();
10077        runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
10078        let runtime = Arc::new(runtime);
10079        let start = Arc::new(Barrier::new(4));
10080        let workers = (0..4)
10081            .map(|_| {
10082                let runtime = Arc::clone(&runtime);
10083                let start = Arc::clone(&start);
10084                std::thread::spawn(move || {
10085                    start.wait();
10086                    runtime.get_kv(b"missing", ReadConsistency::ReadBarrier)
10087                })
10088            })
10089            .collect::<Vec<_>>();
10090
10091        let responses = workers
10092            .into_iter()
10093            .map(|worker| worker.join().unwrap().unwrap())
10094            .collect::<Vec<_>>();
10095        assert!(responses
10096            .iter()
10097            .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
10098        assert_eq!(runtime.log_store().last_index().unwrap(), None);
10099    }
10100
10101    #[cfg(feature = "kv")]
10102    #[test]
10103    fn kv_read_barrier_releases_commit_lock_before_backend_snapshot() {
10104        let (_dir, mut runtime) = kv_test_runtime();
10105        let initial = runtime
10106            .mutate_kv(KvCommandV1::put("request-1", b"key".to_vec(), b"one".to_vec()).unwrap())
10107            .unwrap();
10108        let entered = Arc::new(Barrier::new(2));
10109        let release = Arc::new(Barrier::new(2));
10110        runtime.read_barrier_before_snapshot_hook = Some(Arc::new({
10111            let entered = Arc::clone(&entered);
10112            let release = Arc::clone(&release);
10113            move || {
10114                entered.wait();
10115                release.wait();
10116            }
10117        }));
10118        let runtime = Arc::new(runtime);
10119        let reader = {
10120            let runtime = Arc::clone(&runtime);
10121            std::thread::spawn(move || runtime.get_kv(b"key", ReadConsistency::ReadBarrier))
10122        };
10123        entered.wait();
10124        let (advanced_tx, advanced_rx) = mpsc::channel();
10125        let writer = {
10126            let runtime = Arc::clone(&runtime);
10127            std::thread::spawn(move || {
10128                let outcome = runtime
10129                    .mutate_kv(
10130                        KvCommandV1::put("request-2", b"key".to_vec(), b"two".to_vec()).unwrap(),
10131                    )
10132                    .unwrap();
10133                advanced_tx
10134                    .send((outcome.applied_index(), outcome.hash()))
10135                    .unwrap();
10136                outcome
10137            })
10138        };
10139
10140        let advanced_before_snapshot = advanced_rx.recv_timeout(Duration::from_secs(2));
10141        release.wait();
10142        let written = writer.join().unwrap();
10143        let read = reader.join().unwrap().unwrap();
10144
10145        assert!(
10146            advanced_before_snapshot.is_ok(),
10147            "KV write must advance while the read is paused before its backend snapshot"
10148        );
10149        assert!(read.applied_index >= initial.applied_index());
10150        if read.applied_index == initial.applied_index() {
10151            assert_eq!(read.hash, initial.hash());
10152        }
10153        assert_eq!(read.applied_index, written.applied_index());
10154        assert_eq!(read.hash, written.hash());
10155    }
10156
10157    #[test]
10158    fn client_authentication_rejects_empty_expected_token() {
10159        let mut headers = HeaderMap::new();
10160        headers.insert(VERSION_HEADER, HeaderValue::from_static(PROTOCOL_VERSION));
10161        headers.insert("authorization", HeaderValue::from_static("Bearer "));
10162
10163        assert!(!client_authenticated(&headers, ""));
10164    }
10165
10166    #[test]
10167    fn recorder_record_rejects_oversized_inline_command() {
10168        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
10169        let command = StoredCommand::new(
10170            EntryType::Command,
10171            vec![0_u8; MAX_COMMAND_BYTES.saturating_add(1)],
10172        );
10173        let request = RecordRequest {
10174            cluster_id: "rhiza:sql:node-unit-test".into(),
10175            epoch: 1,
10176            config_id: 1,
10177            config_digest: membership.digest(),
10178            slot: 1,
10179            step: 1,
10180            proposal: Proposal::new(
10181                ProposalPriority::MAX,
10182                "n1",
10183                1,
10184                AcceptedValue::from_command(
10185                    "rhiza:sql:node-unit-test",
10186                    1,
10187                    1,
10188                    1,
10189                    LogHash::ZERO,
10190                    &command,
10191                ),
10192            ),
10193            command: Some(command),
10194        };
10195
10196        assert!(!valid_recorder_record(&request));
10197    }
10198
10199    #[test]
10200    fn sync_flush_retry_doubles_to_a_jitter_free_cap() {
10201        let mut delay = SYNC_FLUSH_RETRY_INITIAL;
10202        let mut delays = Vec::new();
10203        for _ in 0..7 {
10204            delays.push(delay);
10205            delay = next_sync_flush_retry(delay);
10206        }
10207
10208        assert_eq!(
10209            delays,
10210            [50, 100, 200, 400, 800, 1_000, 1_000].map(Duration::from_millis)
10211        );
10212    }
10213
10214    #[test]
10215    fn blocking_operation_offloads_on_current_thread_runtime() {
10216        let runtime = tokio::runtime::Builder::new_current_thread()
10217            .build()
10218            .unwrap();
10219        runtime.block_on(async {
10220            let caller = std::thread::current().id();
10221            let worker = run_read_operation(ReadConsistency::Local, || std::thread::current().id())
10222                .await
10223                .unwrap();
10224
10225            assert_ne!(worker, caller);
10226        });
10227    }
10228
10229    #[test]
10230    fn blocking_operation_runs_inline_on_multi_thread_runtime() {
10231        let runtime = tokio::runtime::Builder::new_multi_thread()
10232            .worker_threads(2)
10233            .build()
10234            .unwrap();
10235        runtime.block_on(async {
10236            let caller = std::thread::current().id();
10237            let worker = run_read_operation(ReadConsistency::AppliedIndex(1), || {
10238                std::thread::current().id()
10239            })
10240            .await
10241            .unwrap();
10242
10243            assert_eq!(worker, caller);
10244        });
10245    }
10246
10247    #[test]
10248    fn read_barrier_offloads_on_multi_thread_runtime() {
10249        let runtime = tokio::runtime::Builder::new_multi_thread()
10250            .worker_threads(2)
10251            .build()
10252            .unwrap();
10253        runtime.block_on(async {
10254            let caller = std::thread::current().id();
10255            let worker =
10256                run_read_operation(ReadConsistency::ReadBarrier, || std::thread::current().id())
10257                    .await
10258                    .unwrap();
10259
10260            assert_ne!(worker, caller);
10261        });
10262    }
10263
10264    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10265    async fn node_service_adaptive_sql_read_returns_point_and_query_results() {
10266        let (_dir, runtime) = sql_test_runtime();
10267        let service = NodeService::new(Arc::new(runtime), None);
10268
10269        let point = service
10270            .read("missing", ReadConsistency::Local)
10271            .await
10272            .unwrap();
10273        let query = service
10274            .query(
10275                SqlStatement {
10276                    sql: "SELECT ?1 AS value".into(),
10277                    parameters: vec![SqlValue::Integer(7)],
10278                },
10279                ReadConsistency::AppliedIndex(0),
10280                1,
10281            )
10282            .await
10283            .unwrap();
10284
10285        assert_eq!(point.value, None);
10286        assert_eq!(query.columns, vec!["value"]);
10287        assert_eq!(query.rows, vec![vec![SqlValue::Integer(7)]]);
10288    }
10289
10290    #[test]
10291    fn node_service_adaptive_sql_read_stays_inline_and_recovers_direct_panic() {
10292        let (_dir, runtime) = sql_test_runtime();
10293        let service = NodeService::new(Arc::new(runtime), None);
10294        let runtime = tokio::runtime::Builder::new_multi_thread()
10295            .worker_threads(2)
10296            .build()
10297            .unwrap();
10298        runtime.block_on(async {
10299            let caller = std::thread::current().id();
10300            let worker = service
10301                .run_sql_read_operation(ReadConsistency::Local, || std::thread::current().id())
10302                .await
10303                .unwrap();
10304
10305            assert_eq!(worker, caller);
10306            assert_eq!(
10307                service
10308                    .sql_reads_in_flight
10309                    .load(std::sync::atomic::Ordering::Acquire),
10310                0
10311            );
10312        });
10313
10314        let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
10315            runtime.block_on(
10316                service.run_sql_read_operation(ReadConsistency::AppliedIndex(0), || -> () {
10317                    panic!("inline SQL read panic")
10318                }),
10319            )
10320        }));
10321        assert!(panic.is_err());
10322        assert_eq!(
10323            service
10324                .sql_reads_in_flight
10325                .load(std::sync::atomic::Ordering::Acquire),
10326            0
10327        );
10328    }
10329
10330    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10331    async fn node_service_adaptive_sql_read_offloads_overlap_and_recovers_join_error() {
10332        let (_dir, runtime) = sql_test_runtime();
10333        let service = NodeService::new(Arc::new(runtime), None);
10334        let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
10335        let (release_tx, release_rx) = std::sync::mpsc::channel();
10336        let first_service = service.clone();
10337        let first = tokio::spawn(async move {
10338            first_service
10339                .run_sql_read_operation(ReadConsistency::Local, move || {
10340                    entered_tx.send(()).unwrap();
10341                    release_rx.recv().unwrap();
10342                })
10343                .await
10344                .unwrap();
10345        });
10346        entered_rx.await.unwrap();
10347        assert_eq!(
10348            service
10349                .sql_reads_in_flight
10350                .load(std::sync::atomic::Ordering::Acquire),
10351            1
10352        );
10353
10354        let caller = std::thread::current().id();
10355        let worker = service
10356            .run_sql_read_operation(ReadConsistency::AppliedIndex(0), || {
10357                std::thread::current().id()
10358            })
10359            .await
10360            .unwrap();
10361        assert_ne!(worker, caller);
10362        assert_eq!(
10363            service
10364                .sql_reads_in_flight
10365                .load(std::sync::atomic::Ordering::Acquire),
10366            1
10367        );
10368
10369        let error = service
10370            .run_sql_read_operation(ReadConsistency::Local, || -> () {
10371                panic!("contended SQL read panic")
10372            })
10373            .await
10374            .unwrap_err();
10375        assert!(error.is_panic());
10376        assert_eq!(
10377            service
10378                .sql_reads_in_flight
10379                .load(std::sync::atomic::Ordering::Acquire),
10380            1
10381        );
10382
10383        release_tx.send(()).unwrap();
10384        first.await.unwrap();
10385        assert_eq!(
10386            service
10387                .sql_reads_in_flight
10388                .load(std::sync::atomic::Ordering::Acquire),
10389            0
10390        );
10391    }
10392
10393    #[test]
10394    fn node_service_adaptive_sql_read_offloads_on_current_thread() {
10395        let (_dir, node) = sql_test_runtime();
10396        let service = NodeService::new(Arc::new(node), None);
10397        let runtime = tokio::runtime::Builder::new_current_thread()
10398            .build()
10399            .unwrap();
10400        runtime.block_on(async {
10401            let caller = std::thread::current().id();
10402            let worker = service
10403                .run_sql_read_operation(ReadConsistency::Local, || std::thread::current().id())
10404                .await
10405                .unwrap();
10406
10407            assert_ne!(worker, caller);
10408            assert_eq!(
10409                service
10410                    .sql_reads_in_flight
10411                    .load(std::sync::atomic::Ordering::Acquire),
10412                0
10413            );
10414        });
10415    }
10416
10417    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10418    async fn node_service_read_barrier_offloads_without_counting_fast_reads() {
10419        let (_dir, runtime) = sql_test_runtime();
10420        let service = NodeService::new(Arc::new(runtime), None);
10421        let caller = std::thread::current().id();
10422        let worker = service
10423            .run_sql_read_operation(ReadConsistency::ReadBarrier, || std::thread::current().id())
10424            .await
10425            .unwrap();
10426
10427        assert_ne!(worker, caller);
10428        assert_eq!(
10429            service
10430                .sql_reads_in_flight
10431                .load(std::sync::atomic::Ordering::Acquire),
10432            0
10433        );
10434    }
10435
10436    #[test]
10437    fn embedded_sql_query_keeps_raw_budget_without_http_json_budget() {
10438        let (_dir, runtime) = sql_test_runtime();
10439        let response = runtime
10440            .query_sql(
10441                &SqlStatement {
10442                    sql: "SELECT replace(hex(zeroblob(700000)), '00', char(1)) AS value".into(),
10443                    parameters: Vec::new(),
10444                },
10445                ReadConsistency::Local,
10446                1,
10447            )
10448            .unwrap();
10449
10450        assert!(serde_json::to_vec(&response).unwrap().len() > MAX_SQL_RESPONSE_BYTES);
10451    }
10452
10453    #[test]
10454    fn sql_http_response_rejects_encoded_body_over_limit() {
10455        let response = SqlQueryResponse {
10456            columns: vec!["value".into()],
10457            rows: vec![vec![SqlValue::Text("\u{1}".repeat(700_000))]],
10458            applied_index: 0,
10459            hash: LogHash::ZERO,
10460        };
10461
10462        assert_eq!(
10463            sql_query_http_response(response).status(),
10464            axum::http::StatusCode::BAD_REQUEST
10465        );
10466    }
10467
10468    #[cfg(feature = "graph")]
10469    #[test]
10470    fn graph_response_work_holds_client_capacity_until_completion() {
10471        let slots = std::sync::Arc::new(tokio::sync::Semaphore::new(1));
10472        let permit = std::sync::Arc::new(slots.clone().try_acquire_owned().unwrap());
10473
10474        let capacity_exhausted_during_response =
10475            with_graph_client_permit(permit, || slots.clone().try_acquire_owned().is_err());
10476
10477        assert!(capacity_exhausted_during_response);
10478        assert!(slots.try_acquire().is_ok());
10479    }
10480
10481    #[cfg(feature = "graph")]
10482    #[test]
10483    fn graph_client_query_error_returns_400_without_latching_readiness() {
10484        let (_dir, runtime) = graph_test_runtime();
10485
10486        let error = runtime.map_graph_read_error(rhiza_graph::Error::InvalidCommand(
10487            "unknown property".into(),
10488        ));
10489        let response = node_error_response(error);
10490
10491        assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
10492        assert!(runtime.is_ready());
10493        assert!(!runtime.is_fatal());
10494    }
10495
10496    #[cfg(feature = "graph")]
10497    #[test]
10498    fn graph_resource_exhaustion_returns_503_without_latching_readiness() {
10499        let (_dir, runtime) = graph_test_runtime();
10500
10501        let error = runtime.map_graph_read_error(rhiza_graph::Error::ResourceExhausted(
10502            "buffer pool is full".into(),
10503        ));
10504        let response = node_error_response(error);
10505
10506        assert_eq!(
10507            response.status(),
10508            axum::http::StatusCode::SERVICE_UNAVAILABLE
10509        );
10510        assert!(runtime.is_ready());
10511        assert!(!runtime.is_fatal());
10512    }
10513
10514    #[cfg(feature = "kv")]
10515    #[test]
10516    fn kv_resource_exhaustion_returns_503_without_latching_readiness() {
10517        let (_dir, runtime) = kv_test_runtime();
10518
10519        let error = runtime.map_kv_read_error(rhiza_kv::Error::ResourceExhausted(
10520            "scan result is too large".into(),
10521        ));
10522        let response = node_error_response(error);
10523
10524        assert_eq!(
10525            response.status(),
10526            axum::http::StatusCode::SERVICE_UNAVAILABLE
10527        );
10528        assert!(runtime.is_ready());
10529        assert!(!runtime.is_fatal());
10530    }
10531
10532    #[cfg(feature = "graph")]
10533    #[test]
10534    fn graph_batch_coalesces_exact_retry_and_isolates_conflicting_duplicate() {
10535        let (_dir, runtime) = graph_test_runtime();
10536        let canonical =
10537            GraphCommandV1::put_document("same", "document", GraphValueV1::String("first".into()))
10538                .unwrap();
10539        let conflict = GraphCommandV1::put_document(
10540            "same",
10541            "document",
10542            GraphValueV1::String("conflict".into()),
10543        )
10544        .unwrap();
10545        let unrelated =
10546            GraphCommandV1::put_document("other", "other", GraphValueV1::U64(2)).unwrap();
10547        let results = runtime
10548            .mutate_graph_batch(vec![canonical.clone(), canonical, conflict, unrelated])
10549            .unwrap();
10550
10551        let canonical = results[0].as_ref().unwrap().applied_index();
10552        assert_eq!(results[1].as_ref().unwrap().applied_index(), canonical);
10553        assert!(matches!(
10554            results[2],
10555            Err(super::NodeError::InvalidRequest(_))
10556        ));
10557        assert_eq!(results[3].as_ref().unwrap().applied_index(), canonical);
10558        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
10559        assert!(runtime.is_ready());
10560    }
10561
10562    #[cfg(feature = "kv")]
10563    #[test]
10564    fn kv_batch_coalesces_exact_retry_and_isolates_conflicting_duplicate() {
10565        let (_dir, runtime) = kv_test_runtime();
10566        let canonical = KvCommandV1::put("same", b"key".to_vec(), b"first".to_vec()).unwrap();
10567        let conflict = KvCommandV1::put("same", b"key".to_vec(), b"conflict".to_vec()).unwrap();
10568        let unrelated = KvCommandV1::put("other", b"other".to_vec(), b"second".to_vec()).unwrap();
10569        let results = runtime
10570            .mutate_kv_batch(vec![canonical.clone(), canonical, conflict, unrelated])
10571            .unwrap();
10572
10573        let canonical = results[0].as_ref().unwrap().applied_index();
10574        assert_eq!(results[1].as_ref().unwrap().applied_index(), canonical);
10575        assert!(matches!(
10576            results[2],
10577            Err(super::NodeError::InvalidRequest(_))
10578        ));
10579        assert_eq!(results[3].as_ref().unwrap().applied_index(), canonical);
10580        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
10581        assert!(runtime.is_ready());
10582    }
10583
10584    #[cfg(feature = "kv")]
10585    #[test]
10586    fn kv_group_commit_coalesces_four_waiting_64_member_calls_into_one_qlog() {
10587        let (_dir, runtime) = kv_test_runtime();
10588        let runtime = Arc::new(runtime);
10589        let commit = runtime.lock_commit().unwrap();
10590        let start = Arc::new(Barrier::new(5));
10591        let workers = (0..4)
10592            .map(|call| {
10593                let runtime = Arc::clone(&runtime);
10594                let start = Arc::clone(&start);
10595                std::thread::spawn(move || {
10596                    let commands = (0..64)
10597                        .map(|member| {
10598                            let id = call * 64 + member;
10599                            KvCommandV1::put(
10600                                format!("kv-group-{id}"),
10601                                format!("key-{id}").into_bytes(),
10602                                vec![u8::try_from(call).unwrap(); 128],
10603                            )
10604                            .unwrap()
10605                        })
10606                        .collect();
10607                    start.wait();
10608                    runtime.mutate_kv_batch(commands)
10609                })
10610            })
10611            .collect::<Vec<_>>();
10612        start.wait();
10613        runtime
10614            .kv_group_commit
10615            .wait_for_pending_calls(4, Duration::from_secs(5));
10616        drop(commit);
10617
10618        let responses = workers
10619            .into_iter()
10620            .map(|worker| worker.join().unwrap().unwrap())
10621            .collect::<Vec<_>>();
10622        let anchors = responses
10623            .iter()
10624            .flatten()
10625            .map(|result| {
10626                let outcome = result.as_ref().unwrap();
10627                (outcome.applied_index(), outcome.hash())
10628            })
10629            .collect::<std::collections::HashSet<_>>();
10630        assert_eq!(anchors.len(), 1);
10631        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
10632    }
10633
10634    #[cfg(feature = "kv")]
10635    #[test]
10636    fn kv_group_commit_rejects_public_257_member_call_before_writing() {
10637        let (_dir, runtime) = kv_test_runtime();
10638        let commands = (0..257)
10639            .map(|id| {
10640                KvCommandV1::put(
10641                    format!("kv-over-{id}"),
10642                    format!("key-{id}").into_bytes(),
10643                    b"value".to_vec(),
10644                )
10645                .unwrap()
10646            })
10647            .collect();
10648
10649        let error = runtime.mutate_kv_batch(commands).unwrap_err();
10650
10651        assert!(matches!(error, NodeError::InvalidRequest(_)));
10652        assert_eq!(runtime.log_store().last_index().unwrap(), None);
10653        assert!(runtime
10654            .kv_group_commit
10655            .state
10656            .lock()
10657            .unwrap()
10658            .pending
10659            .is_empty());
10660    }
10661
10662    #[cfg(feature = "kv")]
10663    #[test]
10664    fn kv_group_commit_lone_call_completes_and_leaves_queue_idle() {
10665        let (_dir, runtime) = kv_test_runtime();
10666
10667        let outcome = runtime
10668            .mutate_kv(KvCommandV1::put("kv-lone", b"key".to_vec(), b"value".to_vec()).unwrap())
10669            .unwrap();
10670
10671        assert_eq!(outcome.applied_index(), 1);
10672        let state = runtime
10673            .kv_group_commit
10674            .state
10675            .lock()
10676            .unwrap_or_else(std::sync::PoisonError::into_inner);
10677        assert!(state.pending.is_empty());
10678        assert_eq!(state.pending_encoded_bytes, 0);
10679        assert!(!state.leader_active);
10680    }
10681
10682    #[cfg(feature = "kv")]
10683    #[test]
10684    fn kv_group_commit_shutdown_wakes_waiters_without_writing() {
10685        let (_dir, runtime) = kv_test_runtime();
10686        let runtime = Arc::new(runtime);
10687        let commit = runtime.lock_commit().unwrap();
10688        let start = Arc::new(Barrier::new(3));
10689        let workers = (0..2)
10690            .map(|id| {
10691                let runtime = Arc::clone(&runtime);
10692                let start = Arc::clone(&start);
10693                std::thread::spawn(move || {
10694                    start.wait();
10695                    runtime.mutate_kv(
10696                        KvCommandV1::put(
10697                            format!("kv-shutdown-{id}"),
10698                            format!("key-{id}").into_bytes(),
10699                            b"value".to_vec(),
10700                        )
10701                        .unwrap(),
10702                    )
10703                })
10704            })
10705            .collect::<Vec<_>>();
10706        start.wait();
10707        runtime
10708            .kv_group_commit
10709            .wait_for_pending_calls(2, Duration::from_secs(5));
10710
10711        runtime.cancel_operations();
10712        drop(commit);
10713
10714        for worker in workers {
10715            assert!(matches!(
10716                worker.join().unwrap(),
10717                Err(NodeError::Unavailable(_))
10718            ));
10719        }
10720        assert_eq!(runtime.log_store().last_index().unwrap(), None);
10721        let state = runtime
10722            .kv_group_commit
10723            .state
10724            .lock()
10725            .unwrap_or_else(std::sync::PoisonError::into_inner);
10726        assert!(state.pending.is_empty());
10727        assert_eq!(state.pending_encoded_bytes, 0);
10728        assert!(!state.leader_active);
10729    }
10730
10731    #[cfg(feature = "kv")]
10732    #[test]
10733    fn kv_group_commit_preserves_cross_call_retry_conflict_and_new_result_offsets() {
10734        let (_dir, runtime) = kv_test_runtime();
10735        let runtime = Arc::new(runtime);
10736        let stored = KvCommandV1::put(
10737            "kv-stored",
10738            b"stored-key".to_vec(),
10739            b"stored-value".to_vec(),
10740        )
10741        .unwrap();
10742        let stored_outcome = runtime.mutate_kv(stored.clone()).unwrap();
10743        let conflict =
10744            KvCommandV1::put("kv-stored", b"stored-key".to_vec(), b"conflict".to_vec()).unwrap();
10745        let commit = runtime.lock_commit().unwrap();
10746        let start = Arc::new(Barrier::new(3));
10747        let retry_worker = {
10748            let runtime = Arc::clone(&runtime);
10749            let start = Arc::clone(&start);
10750            std::thread::spawn(move || {
10751                start.wait();
10752                runtime.mutate_kv_batch(vec![stored, conflict])
10753            })
10754        };
10755        let new_worker = {
10756            let runtime = Arc::clone(&runtime);
10757            let start = Arc::clone(&start);
10758            std::thread::spawn(move || {
10759                start.wait();
10760                runtime.mutate_kv_batch(vec![
10761                    KvCommandV1::put("kv-new-1", b"new-1".to_vec(), b"one".to_vec()).unwrap(),
10762                    KvCommandV1::put("kv-new-2", b"new-2".to_vec(), b"two".to_vec()).unwrap(),
10763                ])
10764            })
10765        };
10766        start.wait();
10767        runtime
10768            .kv_group_commit
10769            .wait_for_pending_calls(2, Duration::from_secs(5));
10770        drop(commit);
10771
10772        let retry_results = retry_worker.join().unwrap().unwrap();
10773        let new_results = new_worker.join().unwrap().unwrap();
10774
10775        assert_eq!(
10776            retry_results[0].as_ref().unwrap().applied_index(),
10777            stored_outcome.applied_index()
10778        );
10779        assert!(matches!(
10780            retry_results[1],
10781            Err(NodeError::InvalidRequest(_))
10782        ));
10783        let new_anchors = new_results
10784            .iter()
10785            .map(|result| {
10786                let outcome = result.as_ref().unwrap();
10787                (outcome.applied_index(), outcome.hash())
10788            })
10789            .collect::<std::collections::HashSet<_>>();
10790        assert_eq!(new_anchors.len(), 1);
10791        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
10792    }
10793
10794    #[cfg(feature = "kv")]
10795    #[test]
10796    fn kv_group_commit_releases_pending_byte_budget_after_drain_and_failure() {
10797        let queue = super::KvGroupCommitQueue::new();
10798        let cancelled = AtomicBool::new(false);
10799        let member = |id: usize, bytes: usize| super::RuntimeBatchMember {
10800            #[cfg(feature = "sql")]
10801            request_id: format!("kv-byte-{id}"),
10802            payload: vec![u8::try_from(id).unwrap_or_default(); bytes],
10803            operation: super::QueuedOperation::Kv(
10804                KvCommandV1::put(
10805                    format!("kv-byte-{id}"),
10806                    format!("key-{id}").into_bytes(),
10807                    b"value".to_vec(),
10808                )
10809                .unwrap(),
10810            ),
10811        };
10812        for id in 0..63 {
10813            queue
10814                .enqueue(vec![member(id, MAX_COMMAND_BYTES)], &cancelled)
10815                .unwrap();
10816        }
10817
10818        let overflow = match queue.enqueue(vec![member(63, MAX_COMMAND_BYTES * 2)], &cancelled) {
10819            Ok(_) => panic!("pending KV byte budget must reject oversized aggregate work"),
10820            Err(error) => error,
10821        };
10822        assert!(matches!(overflow, NodeError::ResourceExhausted(_)));
10823
10824        let drained = queue.drain_next_group().unwrap();
10825        assert_eq!(drained.len(), 63);
10826        let released = queue
10827            .enqueue(vec![member(64, MAX_COMMAND_BYTES)], &cancelled)
10828            .unwrap()
10829            .0;
10830        queue.fail_pending(NodeError::Unavailable("test failure".into()));
10831        assert!(matches!(
10832            released.wait(&cancelled),
10833            Err(NodeError::Unavailable(_))
10834        ));
10835        let state = queue
10836            .state
10837            .lock()
10838            .unwrap_or_else(std::sync::PoisonError::into_inner);
10839        assert!(state.pending.is_empty());
10840        assert_eq!(state.pending_encoded_bytes, 0);
10841        assert!(!state.leader_active);
10842    }
10843
10844    #[cfg(feature = "kv")]
10845    #[test]
10846    fn kv_group_commit_window_restarts_after_staggered_enqueue() {
10847        let queue = Arc::new(super::KvGroupCommitQueue::new());
10848        let cancelled = AtomicBool::new(false);
10849        let member = |id: usize| super::RuntimeBatchMember {
10850            #[cfg(feature = "sql")]
10851            request_id: format!("kv-debounce-{id}"),
10852            payload: vec![u8::try_from(id).unwrap_or_default()],
10853            operation: super::QueuedOperation::Kv(
10854                KvCommandV1::put(
10855                    format!("kv-debounce-{id}"),
10856                    format!("key-{id}").into_bytes(),
10857                    b"value".to_vec(),
10858                )
10859                .unwrap(),
10860            ),
10861        };
10862        queue.enqueue(vec![member(1)], &cancelled).unwrap();
10863        let collector = Arc::clone(&queue);
10864        let (finished, receive) = std::sync::mpsc::channel();
10865        let worker = std::thread::spawn(move || {
10866            let collected = collector.collect_until_full_or_timeout(Duration::from_millis(100));
10867            finished.send(collected).unwrap();
10868        });
10869
10870        std::thread::sleep(Duration::from_millis(75));
10871        queue.enqueue(vec![member(2)], &cancelled).unwrap();
10872        std::thread::sleep(Duration::from_millis(75));
10873        queue.enqueue(vec![member(3)], &cancelled).unwrap();
10874        assert!(receive.recv_timeout(Duration::from_millis(50)).is_err());
10875        assert!(receive.recv_timeout(Duration::from_millis(150)).unwrap());
10876        worker.join().unwrap();
10877        assert_eq!(queue.drain_next_group().unwrap().len(), 3);
10878    }
10879
10880    #[cfg(feature = "kv")]
10881    #[test]
10882    fn kv_group_commit_returns_committed_group_when_cancelled_after_execution() {
10883        let (_dir, mut runtime) = kv_test_runtime();
10884        runtime.kv_group_commit_after_execute_hook = Some(Arc::new(NodeRuntime::cancel_operations));
10885        let runtime = Arc::new(runtime);
10886        let commit = runtime.lock_commit().unwrap();
10887        let start = Arc::new(Barrier::new(3));
10888        let workers = (0..2)
10889            .map(|call| {
10890                let runtime = Arc::clone(&runtime);
10891                let start = Arc::clone(&start);
10892                std::thread::spawn(move || {
10893                    let commands = (0..64)
10894                        .map(|member| {
10895                            let id = call * 64 + member;
10896                            KvCommandV1::put(
10897                                format!("kv-cancel-after-{id}"),
10898                                format!("key-{id}").into_bytes(),
10899                                b"value".to_vec(),
10900                            )
10901                            .unwrap()
10902                        })
10903                        .collect();
10904                    start.wait();
10905                    runtime.mutate_kv_batch(commands)
10906                })
10907            })
10908            .collect::<Vec<_>>();
10909        start.wait();
10910        runtime
10911            .kv_group_commit
10912            .wait_for_pending_calls(2, Duration::from_secs(5));
10913        drop(commit);
10914
10915        let results = workers
10916            .into_iter()
10917            .flat_map(|worker| worker.join().unwrap().unwrap())
10918            .collect::<Vec<_>>();
10919
10920        assert_eq!(results.len(), 128);
10921        let anchors = results
10922            .iter()
10923            .map(|result| {
10924                let outcome = result.as_ref().unwrap();
10925                (outcome.applied_index(), outcome.hash())
10926            })
10927            .collect::<std::collections::HashSet<_>>();
10928        assert_eq!(anchors.len(), 1);
10929        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
10930        assert!(runtime.operation_cancelled.load(Ordering::Acquire));
10931    }
10932
10933    #[cfg(feature = "kv")]
10934    #[test]
10935    fn kv_largest_fitting_prefix_is_exact_for_large_grouped_batch() {
10936        let commands = (0..256)
10937            .map(|id| {
10938                KvCommandV1::put(
10939                    format!("kv-prefix-{id:04}"),
10940                    format!("key-{id:04}").into_bytes(),
10941                    vec![b'x'; 4 * 1024],
10942                )
10943                .unwrap()
10944            })
10945            .collect::<Vec<_>>();
10946        assert!(super::encode_replicated_kv_batch(&commands).unwrap().len() > MAX_COMMAND_BYTES);
10947        let expected = (2..commands.len())
10948            .filter(|count| {
10949                super::encode_replicated_kv_batch(&commands[..*count])
10950                    .unwrap()
10951                    .len()
10952                    <= MAX_COMMAND_BYTES
10953            })
10954            .max()
10955            .unwrap();
10956
10957        let (count, payload) = super::largest_fitting_kv_batch_prefix(&commands).unwrap();
10958
10959        assert_eq!(count, expected);
10960        assert!(payload.len() <= MAX_COMMAND_BYTES);
10961        assert!(
10962            super::encode_replicated_kv_batch(&commands[..count + 1])
10963                .unwrap()
10964                .len()
10965                > MAX_COMMAND_BYTES
10966        );
10967    }
10968
10969    #[cfg(feature = "kv")]
10970    #[test]
10971    fn kv_group_commit_large_batch_uses_largest_fitting_fifo_sub_batches() {
10972        let (_dir, runtime) = kv_test_runtime();
10973        let runtime = Arc::new(runtime);
10974        let calls = (0..4)
10975            .map(|call| {
10976                (0..64)
10977                    .map(|member| {
10978                        let id = call * 64 + member;
10979                        KvCommandV1::put(
10980                            format!("kv-large-{id:04}"),
10981                            format!("key-{id:04}").into_bytes(),
10982                            vec![b'x'; 4 * 1024],
10983                        )
10984                        .unwrap()
10985                    })
10986                    .collect::<Vec<_>>()
10987            })
10988            .collect::<Vec<_>>();
10989        let flattened = calls.iter().flatten().cloned().collect::<Vec<_>>();
10990        let (largest_prefix, _) = super::largest_fitting_kv_batch_prefix(&flattened).unwrap();
10991        let expected_entries = flattened.len().div_ceil(largest_prefix);
10992        let commit = runtime.lock_commit().unwrap();
10993        let start = Arc::new(Barrier::new(5));
10994        let workers = calls
10995            .into_iter()
10996            .map(|commands| {
10997                let runtime = Arc::clone(&runtime);
10998                let start = Arc::clone(&start);
10999                std::thread::spawn(move || {
11000                    start.wait();
11001                    runtime.mutate_kv_batch(commands)
11002                })
11003            })
11004            .collect::<Vec<_>>();
11005        start.wait();
11006        runtime
11007            .kv_group_commit
11008            .wait_for_pending_calls(4, Duration::from_secs(5));
11009        drop(commit);
11010
11011        let mut counts = std::collections::BTreeMap::new();
11012        for result in workers
11013            .into_iter()
11014            .flat_map(|worker| worker.join().unwrap().unwrap())
11015        {
11016            *counts
11017                .entry(result.unwrap().applied_index())
11018                .or_insert(0_usize) += 1;
11019        }
11020
11021        assert_eq!(counts.len(), expected_entries);
11022        let counts = counts.into_values().collect::<Vec<_>>();
11023        assert!(counts[..counts.len() - 1]
11024            .iter()
11025            .all(|count| *count == largest_prefix));
11026        assert_eq!(counts.iter().sum::<usize>(), 256);
11027        assert_eq!(
11028            runtime.log_store().last_index().unwrap(),
11029            Some(u64::try_from(expected_entries).unwrap())
11030        );
11031        for index in 1..=u64::try_from(expected_entries).unwrap() {
11032            assert!(runtime
11033                .log_store()
11034                .read(index)
11035                .unwrap()
11036                .is_some_and(|entry| entry.payload.len() <= MAX_COMMAND_BYTES));
11037        }
11038    }
11039
11040    #[cfg(feature = "kv")]
11041    #[test]
11042    fn kv_group_commit_leader_panic_wakes_active_and_pending_calls_with_same_fatal() {
11043        let (_dir, mut runtime) = kv_test_runtime();
11044        runtime.kv_group_commit_before_execute_hook =
11045            Some(Arc::new(|| panic!("injected KV group leader panic")));
11046        let runtime = Arc::new(runtime);
11047        let commit = runtime.lock_commit().unwrap();
11048        let mut workers = Vec::new();
11049        for call in 0..17 {
11050            let worker_runtime = Arc::clone(&runtime);
11051            workers.push(std::thread::spawn(move || {
11052                worker_runtime.mutate_kv_batch(
11053                    (0..64)
11054                        .map(|member| {
11055                            let id = call * 64 + member;
11056                            KvCommandV1::put(
11057                                format!("kv-panic-{id}"),
11058                                format!("key-{id}").into_bytes(),
11059                                b"value".to_vec(),
11060                            )
11061                            .unwrap()
11062                        })
11063                        .collect(),
11064                )
11065            }));
11066            runtime
11067                .kv_group_commit
11068                .wait_for_pending_calls(call + 1, Duration::from_secs(5));
11069        }
11070        drop(commit);
11071
11072        let errors = workers
11073            .into_iter()
11074            .map(|worker| worker.join().unwrap().unwrap_err())
11075            .collect::<Vec<_>>();
11076        assert!(errors
11077            .iter()
11078            .all(|error| matches!(error, NodeError::Fatal(_))));
11079        assert_eq!(errors[0].to_string(), errors[1].to_string());
11080        assert!(runtime.is_fatal());
11081        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11082    }
11083
11084    #[cfg(feature = "kv")]
11085    #[test]
11086    fn kv_group_commit_reopens_all_four_grouped_calls_at_shared_durable_anchor() {
11087        let (dir, runtime) = kv_test_runtime();
11088        let runtime = Arc::new(runtime);
11089        let commit = runtime.lock_commit().unwrap();
11090        let start = Arc::new(Barrier::new(5));
11091        let workers = (0..4)
11092            .map(|call| {
11093                let runtime = Arc::clone(&runtime);
11094                let start = Arc::clone(&start);
11095                std::thread::spawn(move || {
11096                    let commands = (0..64)
11097                        .map(|member| {
11098                            let id = call * 64 + member;
11099                            KvCommandV1::put(
11100                                format!("kv-reopen-{id}"),
11101                                format!("key-{id:04}").into_bytes(),
11102                                vec![u8::try_from(call).unwrap(); 128],
11103                            )
11104                            .unwrap()
11105                        })
11106                        .collect();
11107                    start.wait();
11108                    runtime.mutate_kv_batch(commands)
11109                })
11110            })
11111            .collect::<Vec<_>>();
11112        start.wait();
11113        runtime
11114            .kv_group_commit
11115            .wait_for_pending_calls(4, Duration::from_secs(5));
11116        drop(commit);
11117
11118        let results = workers
11119            .into_iter()
11120            .flat_map(|worker| worker.join().unwrap().unwrap())
11121            .collect::<Vec<_>>();
11122        let anchors = results
11123            .iter()
11124            .map(|result| {
11125                let outcome = result.as_ref().unwrap();
11126                (outcome.applied_index(), outcome.hash())
11127            })
11128            .collect::<std::collections::HashSet<_>>();
11129        let [(applied_index, applied_hash)] = anchors.into_iter().collect::<Vec<_>>()[..] else {
11130            panic!("four grouped calls must share one durable anchor");
11131        };
11132        assert_eq!(
11133            runtime.log_store().last_index().unwrap(),
11134            Some(applied_index)
11135        );
11136        let config = runtime.config().clone();
11137        drop(runtime);
11138
11139        let consensus = Arc::new(
11140            ThreeNodeConsensus::from_recovered_tip(
11141                config.cluster_id().to_owned(),
11142                config.node_id().to_owned(),
11143                config.epoch(),
11144                config.config_id(),
11145                [
11146                    dir.path().join("recorders/n1"),
11147                    dir.path().join("recorders/n2"),
11148                    dir.path().join("recorders/n3"),
11149                ],
11150                applied_index + 1,
11151                applied_hash,
11152            )
11153            .unwrap(),
11154        );
11155        let reopened = NodeRuntime::open(config, consensus, &[]).unwrap();
11156
11157        assert_eq!(reopened.applied_index().unwrap(), applied_index);
11158        assert_eq!(reopened.applied_hash().unwrap(), applied_hash);
11159        assert_eq!(reopened.log_store().last_index().unwrap(), Some(1));
11160        for id in 0..256 {
11161            let response = reopened
11162                .get_kv(format!("key-{id:04}").as_bytes(), ReadConsistency::Local)
11163                .unwrap();
11164            assert_eq!(
11165                response.value,
11166                Some(vec![u8::try_from(id / 64).unwrap(); 128])
11167            );
11168            assert_eq!(response.applied_index, applied_index);
11169            assert_eq!(response.hash, applied_hash);
11170        }
11171    }
11172
11173    #[test]
11174    fn sql_batch_preflight_rejects_entire_vector_without_growing_log() {
11175        let (_dir, runtime) = sql_test_runtime();
11176        let valid = SqlCommand {
11177            request_id: "valid".into(),
11178            statements: vec![SqlStatement {
11179                sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY)".into(),
11180                parameters: vec![],
11181            }],
11182        };
11183        let invalid = SqlCommand {
11184            request_id: String::new(),
11185            statements: valid.statements.clone(),
11186        };
11187
11188        let error = runtime.execute_sql_batch(vec![valid, invalid]).unwrap_err();
11189
11190        assert!(matches!(error, NodeError::InvalidRequest(_)));
11191        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11192    }
11193
11194    #[test]
11195    fn sql_batch_rejects_aggregate_encoded_input_over_command_cap_before_io() {
11196        let (_dir, runtime) = sql_test_runtime();
11197        let command = |request_id: &str, fill: char| SqlCommand {
11198            request_id: request_id.into(),
11199            statements: vec![SqlStatement {
11200                sql: "SELECT ?1".into(),
11201                parameters: vec![SqlValue::Text(
11202                    std::iter::repeat_n(fill, MAX_COMMAND_BYTES / 2).collect(),
11203                )],
11204            }],
11205        };
11206
11207        let error = runtime
11208            .execute_sql_batch(vec![
11209                command("aggregate-a", 'a'),
11210                command("aggregate-b", 'b'),
11211            ])
11212            .unwrap_err();
11213
11214        assert!(matches!(error, NodeError::ResourceExhausted(_)));
11215        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11216        assert!(runtime
11217            .sql_group_commit
11218            .state
11219            .lock()
11220            .unwrap()
11221            .pending
11222            .is_empty());
11223    }
11224
11225    #[test]
11226    fn sql_write_profiling_records_nothing_when_observer_is_not_installed() {
11227        let profiler = SqlWriteProfiler::new(8);
11228        let (_dir, runtime) = sql_test_runtime();
11229
11230        runtime
11231            .execute_sql(SqlCommand {
11232                request_id: "schema".into(),
11233                statements: vec![SqlStatement {
11234                    sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11235                    parameters: vec![],
11236                }],
11237            })
11238            .unwrap();
11239
11240        assert!(runtime.config().sql_write_profiler().is_none());
11241        assert!(profiler.snapshot().samples.is_empty());
11242    }
11243
11244    #[test]
11245    fn sql_write_profiling_records_one_consistent_sample_for_one_physical_batch() {
11246        let profiler = SqlWriteProfiler::new(8);
11247        let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11248        runtime
11249            .execute_sql(SqlCommand {
11250                request_id: "schema".into(),
11251                statements: vec![SqlStatement {
11252                    sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11253                    parameters: vec![],
11254                }],
11255            })
11256            .unwrap();
11257        profiler.drain();
11258
11259        let commands = (1..=3)
11260            .map(|id| SqlCommand {
11261                request_id: format!("insert-{id}"),
11262                statements: vec![SqlStatement {
11263                    sql: "INSERT INTO profiled_items(id) VALUES (?1)".into(),
11264                    parameters: vec![SqlValue::Integer(id)],
11265                }],
11266            })
11267            .collect();
11268        let responses = runtime.execute_sql_batch(commands).unwrap();
11269
11270        let snapshot = profiler.snapshot();
11271        assert_eq!(snapshot.dropped_samples, 0);
11272        let [sample] = snapshot.samples.as_slice() else {
11273            panic!("one physical SQL batch must emit one sample: {snapshot:?}");
11274        };
11275        assert_eq!(sample.batch_member_count, 3);
11276        assert_eq!(
11277            sample.total_service_us,
11278            sample
11279                .commit_lock_wait_us
11280                .saturating_add(sample.precheck_classification_us)
11281                .saturating_add(sample.qwal_prepare_us)
11282                .saturating_add(sample.consensus_propose_us)
11283                .saturating_add(sample.local_qlog_mirror_append_us)
11284                .saturating_add(sample.sql_materializer_apply_us)
11285                .saturating_add(sample.response_other_total_us)
11286        );
11287        let (applied_index, applied_hash) = runtime.ensure_materialized_tip().unwrap();
11288        assert!(responses.iter().all(|response| {
11289            response.as_ref().is_ok_and(|response| {
11290                response.applied_index == applied_index && response.hash == applied_hash
11291            })
11292        }));
11293    }
11294
11295    #[test]
11296    fn sql_write_profiling_does_not_fabricate_sample_for_failed_batch() {
11297        let profiler = SqlWriteProfiler::new(8);
11298        let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11299        runtime
11300            .execute_sql(SqlCommand {
11301                request_id: "schema".into(),
11302                statements: vec![SqlStatement {
11303                    sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11304                    parameters: vec![],
11305                }],
11306            })
11307            .unwrap();
11308        runtime
11309            .execute_sql(SqlCommand {
11310                request_id: "first".into(),
11311                statements: vec![SqlStatement {
11312                    sql: "INSERT INTO profiled_items(id) VALUES (1)".into(),
11313                    parameters: vec![],
11314                }],
11315            })
11316            .unwrap();
11317        profiler.drain();
11318        let last_index = runtime.log_store().last_index().unwrap();
11319
11320        let error = runtime
11321            .execute_sql(SqlCommand {
11322                request_id: "duplicate".into(),
11323                statements: vec![SqlStatement {
11324                    sql: "INSERT INTO profiled_items(id) VALUES (1)".into(),
11325                    parameters: vec![],
11326                }],
11327            })
11328            .unwrap_err();
11329
11330        assert!(matches!(error, NodeError::InvalidSqlStatement { .. }));
11331        assert!(profiler.snapshot().samples.is_empty());
11332        assert_eq!(runtime.log_store().last_index().unwrap(), last_index);
11333    }
11334
11335    #[test]
11336    fn sql_group_commit_coalesces_four_waiting_typed_calls_into_one_1024_receipt_qwal() {
11337        let profiler = SqlWriteProfiler::new(8);
11338        let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11339        let runtime = Arc::new(runtime);
11340        runtime
11341            .execute_sql(SqlCommand {
11342                request_id: "group-schema".into(),
11343                statements: vec![SqlStatement {
11344                    sql: "CREATE TABLE grouped_items(id INTEGER PRIMARY KEY)".into(),
11345                    parameters: vec![],
11346                }],
11347            })
11348            .unwrap();
11349        profiler.drain();
11350
11351        let commit = runtime.lock_commit().unwrap();
11352        let start = Arc::new(Barrier::new(5));
11353        let workers = (0..4)
11354            .map(|call| {
11355                let runtime = Arc::clone(&runtime);
11356                let start = Arc::clone(&start);
11357                std::thread::spawn(move || {
11358                    let commands = (0..256)
11359                        .map(|offset| {
11360                            let id = call * 256 + offset;
11361                            SqlCommand {
11362                                request_id: format!("group-{id}"),
11363                                statements: vec![SqlStatement {
11364                                    sql: "INSERT INTO grouped_items(id) VALUES (?1)".into(),
11365                                    parameters: vec![SqlValue::Integer(id)],
11366                                }],
11367                            }
11368                        })
11369                        .collect();
11370                    start.wait();
11371                    runtime.execute_sql_batch(commands).unwrap()
11372                })
11373            })
11374            .collect::<Vec<_>>();
11375        start.wait();
11376        runtime
11377            .sql_group_commit
11378            .wait_for_pending_calls(4, Duration::from_secs(5));
11379        drop(commit);
11380
11381        let results = workers
11382            .into_iter()
11383            .flat_map(|worker| worker.join().unwrap())
11384            .collect::<Vec<_>>();
11385        assert_eq!(results.len(), 1024);
11386        let first = results[0].as_ref().unwrap();
11387        assert!(results.iter().all(|result| {
11388            result.as_ref().is_ok_and(|result| {
11389                result.applied_index == first.applied_index && result.hash == first.hash
11390            })
11391        }));
11392        let entry = runtime
11393            .log_store()
11394            .read(first.applied_index)
11395            .unwrap()
11396            .unwrap();
11397        assert_eq!(
11398            rhiza_sql::decode_qwal_v3(&entry.payload)
11399                .unwrap()
11400                .receipts
11401                .len(),
11402            1024
11403        );
11404        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11405        let snapshot = profiler.snapshot();
11406        let [sample] = snapshot.samples.as_slice() else {
11407            panic!("one grouped physical commit must emit one sample: {snapshot:?}");
11408        };
11409        assert_eq!(sample.batch_member_count, 1024);
11410    }
11411
11412    #[test]
11413    fn sql_group_commit_keeps_fifth_whole_call_for_the_next_physical_group() {
11414        let (_dir, runtime) = sql_test_runtime();
11415        let runtime = Arc::new(runtime);
11416        runtime
11417            .execute_sql(SqlCommand {
11418                request_id: "next-group-schema".into(),
11419                statements: vec![SqlStatement {
11420                    sql: "CREATE TABLE next_group_items(id INTEGER PRIMARY KEY)".into(),
11421                    parameters: vec![],
11422                }],
11423            })
11424            .unwrap();
11425
11426        let commit = runtime.lock_commit().unwrap();
11427        let mut workers = Vec::new();
11428        for call in 0..5 {
11429            let worker_runtime = Arc::clone(&runtime);
11430            workers.push(std::thread::spawn(move || {
11431                worker_runtime
11432                    .execute_sql_batch(
11433                        (0..256)
11434                            .map(|offset| {
11435                                let id = call * 256 + offset;
11436                                SqlCommand {
11437                                    request_id: format!("next-group-{id}"),
11438                                    statements: vec![SqlStatement {
11439                                        sql: "INSERT INTO next_group_items(id) VALUES (?1)".into(),
11440                                        parameters: vec![SqlValue::Integer(id)],
11441                                    }],
11442                                }
11443                            })
11444                            .collect(),
11445                    )
11446                    .unwrap()
11447            }));
11448            runtime
11449                .sql_group_commit
11450                .wait_for_pending_calls(call as usize + 1, Duration::from_secs(5));
11451        }
11452        drop(commit);
11453
11454        let calls = workers
11455            .into_iter()
11456            .map(|worker| worker.join().unwrap())
11457            .collect::<Vec<_>>();
11458        for call in &calls[..4] {
11459            assert!(call
11460                .iter()
11461                .all(|result| result.as_ref().unwrap().applied_index == 2));
11462        }
11463        assert!(calls[4]
11464            .iter()
11465            .all(|result| result.as_ref().unwrap().applied_index == 3));
11466        assert_eq!(
11467            rhiza_sql::decode_qwal_v3(&runtime.log_store().read(2).unwrap().unwrap().payload)
11468                .unwrap()
11469                .receipts
11470                .len(),
11471            1024
11472        );
11473        assert_eq!(
11474            rhiza_sql::decode_qwal_v3(&runtime.log_store().read(3).unwrap().unwrap().payload)
11475                .unwrap()
11476                .receipts
11477                .len(),
11478            256
11479        );
11480    }
11481
11482    #[test]
11483    fn sql_group_commit_preserves_fifo_call_offsets_for_retries_conflicts_aliases_and_failures() {
11484        let (_dir, runtime) = sql_test_runtime();
11485        let runtime = Arc::new(runtime);
11486        runtime
11487            .execute_sql(SqlCommand {
11488                request_id: "fifo-schema".into(),
11489                statements: vec![SqlStatement {
11490                    sql: "CREATE TABLE fifo_items(id INTEGER PRIMARY KEY, value TEXT UNIQUE)"
11491                        .into(),
11492                    parameters: vec![],
11493                }],
11494            })
11495            .unwrap();
11496        let stored_command = SqlCommand {
11497            request_id: "fifo-stored".into(),
11498            statements: vec![SqlStatement {
11499                sql: "INSERT INTO fifo_items(id, value) VALUES (1, 'stored')".into(),
11500                parameters: vec![],
11501            }],
11502        };
11503        let stored = runtime.execute_sql(stored_command.clone()).unwrap();
11504        let valid_alias = SqlCommand {
11505            request_id: "fifo-alias".into(),
11506            statements: vec![SqlStatement {
11507                sql: "INSERT INTO fifo_items(id, value) VALUES (2, 'alias')".into(),
11508                parameters: vec![],
11509            }],
11510        };
11511        let conflict = SqlCommand {
11512            request_id: stored_command.request_id.clone(),
11513            statements: vec![SqlStatement {
11514                sql: "INSERT INTO fifo_items(id, value) VALUES (3, 'conflict')".into(),
11515                parameters: vec![],
11516            }],
11517        };
11518        let failed = SqlCommand {
11519            request_id: "fifo-failed".into(),
11520            statements: vec![SqlStatement {
11521                sql: "INSERT INTO fifo_items(id, value) VALUES (4, 'stored')".into(),
11522                parameters: vec![],
11523            }],
11524        };
11525        let valid = SqlCommand {
11526            request_id: "fifo-valid".into(),
11527            statements: vec![SqlStatement {
11528                sql: "INSERT INTO fifo_items(id, value) VALUES (5, 'valid')".into(),
11529                parameters: vec![],
11530            }],
11531        };
11532
11533        let commit = runtime.lock_commit().unwrap();
11534        let first_runtime = Arc::clone(&runtime);
11535        let first = std::thread::spawn(move || {
11536            first_runtime
11537                .execute_sql_batch(vec![
11538                    stored_command,
11539                    conflict,
11540                    valid_alias.clone(),
11541                    valid_alias,
11542                ])
11543                .unwrap()
11544        });
11545        runtime
11546            .sql_group_commit
11547            .wait_for_pending_calls(1, Duration::from_secs(5));
11548        let second_runtime = Arc::clone(&runtime);
11549        let second = std::thread::spawn(move || {
11550            second_runtime
11551                .execute_sql_batch(vec![failed, valid])
11552                .unwrap()
11553        });
11554        runtime
11555            .sql_group_commit
11556            .wait_for_pending_calls(2, Duration::from_secs(5));
11557        drop(commit);
11558
11559        let first = first.join().unwrap();
11560        let second = second.join().unwrap();
11561        assert_eq!(
11562            first[0].as_ref().unwrap().applied_index,
11563            stored.applied_index
11564        );
11565        assert!(matches!(first[1], Err(NodeError::RequestConflict(_))));
11566        assert_eq!(first[2], first[3]);
11567        assert_eq!(first[2].as_ref().unwrap().applied_index, 3);
11568        assert!(matches!(
11569            second[0],
11570            Err(NodeError::InvalidSqlStatement { .. })
11571        ));
11572        assert_eq!(second[1].as_ref().unwrap().applied_index, 3);
11573    }
11574
11575    #[test]
11576    fn sql_group_commit_rejects_overload_before_enqueue_without_orphaning_the_leader() {
11577        let (_dir, runtime) = sql_test_runtime_configured(None, Some(1));
11578        let runtime = Arc::new(runtime);
11579        runtime
11580            .execute_sql(SqlCommand {
11581                request_id: "overload-schema".into(),
11582                statements: vec![SqlStatement {
11583                    sql: "CREATE TABLE overload_items(id INTEGER PRIMARY KEY)".into(),
11584                    parameters: vec![],
11585                }],
11586            })
11587            .unwrap();
11588        let command = |request_id: &str, id| SqlCommand {
11589            request_id: request_id.into(),
11590            statements: vec![SqlStatement {
11591                sql: "INSERT INTO overload_items(id) VALUES (?1)".into(),
11592                parameters: vec![SqlValue::Integer(id)],
11593            }],
11594        };
11595
11596        let commit = runtime.lock_commit().unwrap();
11597        let leader_runtime = Arc::clone(&runtime);
11598        let leader = std::thread::spawn(move || {
11599            leader_runtime.execute_sql_batch(vec![command("overload-first", 1)])
11600        });
11601        runtime
11602            .sql_group_commit
11603            .wait_for_pending_calls(1, Duration::from_secs(5));
11604        let overload = runtime
11605            .execute_sql_batch(vec![command("overload-second", 2)])
11606            .unwrap_err();
11607        assert!(matches!(overload, NodeError::ResourceExhausted(_)));
11608        drop(commit);
11609        assert!(leader.join().unwrap().unwrap()[0].is_ok());
11610        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11611    }
11612
11613    #[test]
11614    fn sql_group_commit_bounds_pending_bytes_and_releases_reservations() {
11615        let queue = super::SqlGroupCommitQueue::new(super::MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY);
11616        let cancelled = AtomicBool::new(false);
11617        let member = |id: usize| super::RuntimeBatchMember {
11618            request_id: format!("queued-{id}"),
11619            payload: vec![u8::try_from(id).unwrap_or_default(); MAX_COMMAND_BYTES],
11620            operation: super::QueuedOperation::Sql(SqlCommand {
11621                request_id: format!("queued-{id}"),
11622                statements: vec![SqlStatement {
11623                    sql: "SELECT 1".into(),
11624                    parameters: vec![],
11625                }],
11626            }),
11627        };
11628        let mut queued = Vec::new();
11629        for id in 0..super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY {
11630            queued.push(queue.enqueue(vec![member(id)], &cancelled).unwrap().0);
11631        }
11632
11633        let overflow = match queue.enqueue(
11634            vec![member(super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY)],
11635            &cancelled,
11636        ) {
11637            Ok(_) => panic!("pending byte budget must reject one more full command"),
11638            Err(error) => error,
11639        };
11640        assert!(matches!(overflow, NodeError::ResourceExhausted(_)));
11641
11642        let drained = queue.drain_next_group().unwrap();
11643        assert_eq!(drained.len(), 4);
11644        let released = queue
11645            .enqueue(
11646                vec![member(super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY + 1)],
11647                &cancelled,
11648            )
11649            .unwrap()
11650            .0;
11651
11652        queue.fail_pending(NodeError::Unavailable("test failure".into()));
11653        for job in queued.into_iter().skip(drained.len()) {
11654            assert!(matches!(
11655                job.wait(&cancelled),
11656                Err(NodeError::Unavailable(_))
11657            ));
11658        }
11659        assert!(matches!(
11660            released.wait(&cancelled),
11661            Err(NodeError::Unavailable(_))
11662        ));
11663        let state = queue
11664            .state
11665            .lock()
11666            .unwrap_or_else(std::sync::PoisonError::into_inner);
11667        assert!(state.pending.is_empty());
11668        assert_eq!(state.pending_encoded_bytes, 0);
11669        assert!(!state.leader_active);
11670    }
11671
11672    #[test]
11673    fn sql_group_commit_window_restarts_after_staggered_enqueue() {
11674        let queue = Arc::new(super::SqlGroupCommitQueue::new(
11675            super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY,
11676        ));
11677        let cancelled = AtomicBool::new(false);
11678        let member = |id: usize| super::RuntimeBatchMember {
11679            request_id: format!("sql-debounce-{id}"),
11680            payload: vec![u8::try_from(id).unwrap_or_default()],
11681            operation: super::QueuedOperation::Sql(SqlCommand {
11682                request_id: format!("sql-debounce-{id}"),
11683                statements: vec![SqlStatement {
11684                    sql: "SELECT 1".into(),
11685                    parameters: vec![],
11686                }],
11687            }),
11688        };
11689        queue.enqueue(vec![member(1)], &cancelled).unwrap();
11690        let collector = Arc::clone(&queue);
11691        let (finished, receive) = std::sync::mpsc::channel();
11692        let worker = std::thread::spawn(move || {
11693            let collected = collector.collect_until_full_or_timeout(Duration::from_millis(100));
11694            finished.send(collected).unwrap();
11695        });
11696
11697        std::thread::sleep(Duration::from_millis(75));
11698        queue.enqueue(vec![member(2)], &cancelled).unwrap();
11699        std::thread::sleep(Duration::from_millis(75));
11700        queue.enqueue(vec![member(3)], &cancelled).unwrap();
11701        assert!(receive.recv_timeout(Duration::from_millis(50)).is_err());
11702        assert!(receive.recv_timeout(Duration::from_millis(150)).unwrap());
11703        worker.join().unwrap();
11704        assert_eq!(queue.drain_next_group().unwrap().len(), 3);
11705    }
11706
11707    #[test]
11708    fn sql_group_commit_leader_panic_wakes_every_queued_call_with_the_same_fatal_error() {
11709        let (_dir, mut runtime) = sql_test_runtime();
11710        runtime
11711            .execute_sql(SqlCommand {
11712                request_id: "panic-schema".into(),
11713                statements: vec![SqlStatement {
11714                    sql: "CREATE TABLE panic_items(id INTEGER PRIMARY KEY)".into(),
11715                    parameters: vec![],
11716                }],
11717            })
11718            .unwrap();
11719        runtime.sql_group_commit_before_execute_hook =
11720            Some(Arc::new(|| panic!("injected SQL group leader panic")));
11721        let runtime = Arc::new(runtime);
11722        let commit = runtime.lock_commit().unwrap();
11723        let mut workers = Vec::new();
11724        for id in 1..=2 {
11725            let worker_runtime = Arc::clone(&runtime);
11726            workers.push(std::thread::spawn(move || {
11727                worker_runtime.execute_sql_batch(vec![SqlCommand {
11728                    request_id: format!("panic-{id}"),
11729                    statements: vec![SqlStatement {
11730                        sql: "INSERT INTO panic_items(id) VALUES (?1)".into(),
11731                        parameters: vec![SqlValue::Integer(id)],
11732                    }],
11733                }])
11734            }));
11735            runtime
11736                .sql_group_commit
11737                .wait_for_pending_calls(id as usize, Duration::from_secs(5));
11738        }
11739        drop(commit);
11740
11741        let errors = workers
11742            .into_iter()
11743            .map(|worker| worker.join().unwrap().unwrap_err())
11744            .collect::<Vec<_>>();
11745        assert!(errors
11746            .iter()
11747            .all(|error| matches!(error, NodeError::Fatal(_))));
11748        assert_eq!(errors[0].to_string(), errors[1].to_string());
11749        assert!(runtime.is_fatal());
11750    }
11751
11752    #[test]
11753    fn sql_group_commit_shutdown_wakes_queued_calls_with_the_same_unavailable_error() {
11754        let (_dir, runtime) = sql_test_runtime();
11755        let runtime = Arc::new(runtime);
11756        runtime
11757            .execute_sql(SqlCommand {
11758                request_id: "shutdown-schema".into(),
11759                statements: vec![SqlStatement {
11760                    sql: "CREATE TABLE shutdown_items(id INTEGER PRIMARY KEY)".into(),
11761                    parameters: vec![],
11762                }],
11763            })
11764            .unwrap();
11765        let command = |id| SqlCommand {
11766            request_id: format!("shutdown-{id}"),
11767            statements: vec![SqlStatement {
11768                sql: "INSERT INTO shutdown_items(id) VALUES (?1)".into(),
11769                parameters: vec![SqlValue::Integer(id)],
11770            }],
11771        };
11772
11773        let commit = runtime.lock_commit().unwrap();
11774        let leader_runtime = Arc::clone(&runtime);
11775        let leader = std::thread::spawn(move || leader_runtime.execute_sql_batch(vec![command(1)]));
11776        runtime
11777            .sql_group_commit
11778            .wait_for_pending_calls(1, Duration::from_secs(5));
11779        let follower_runtime = Arc::clone(&runtime);
11780        let follower =
11781            std::thread::spawn(move || follower_runtime.execute_sql_batch(vec![command(2)]));
11782        runtime
11783            .sql_group_commit
11784            .wait_for_pending_calls(2, Duration::from_secs(5));
11785
11786        runtime.cancel_operations();
11787        let follower_error = follower.join().unwrap().unwrap_err();
11788        drop(commit);
11789        let leader_error = leader.join().unwrap().unwrap_err();
11790
11791        assert!(matches!(leader_error, NodeError::Unavailable(_)));
11792        assert_eq!(leader_error.to_string(), follower_error.to_string());
11793        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11794    }
11795
11796    #[test]
11797    fn sql_group_commit_reprepares_combined_calls_after_a_foreign_slot_winner() {
11798        let (_dir, runtime) = sql_test_runtime();
11799        let runtime = Arc::new(runtime);
11800        let schema = runtime
11801            .execute_sql(SqlCommand {
11802                request_id: "group-winner-schema".into(),
11803                statements: vec![SqlStatement {
11804                    sql: "CREATE TABLE group_winner(id INTEGER PRIMARY KEY)".into(),
11805                    parameters: vec![],
11806                }],
11807            })
11808            .unwrap();
11809        let winner = runtime
11810            .consensus()
11811            .propose_at(
11812                2,
11813                schema.hash,
11814                Command::new(CommandKind::ReadBarrier, Vec::new()),
11815            )
11816            .unwrap();
11817
11818        let commit = runtime.lock_commit().unwrap();
11819        let mut workers = Vec::new();
11820        for id in 1..=2 {
11821            let worker_runtime = Arc::clone(&runtime);
11822            workers.push(std::thread::spawn(move || {
11823                worker_runtime
11824                    .execute_sql_batch(vec![SqlCommand {
11825                        request_id: format!("group-winner-{id}"),
11826                        statements: vec![SqlStatement {
11827                            sql: "INSERT INTO group_winner(id) VALUES (?1)".into(),
11828                            parameters: vec![SqlValue::Integer(id)],
11829                        }],
11830                    }])
11831                    .unwrap()
11832            }));
11833            runtime
11834                .sql_group_commit
11835                .wait_for_pending_calls(id as usize, Duration::from_secs(5));
11836        }
11837        drop(commit);
11838
11839        let results = workers
11840            .into_iter()
11841            .flat_map(|worker| worker.join().unwrap())
11842            .collect::<Vec<_>>();
11843        assert_eq!(runtime.log_store().read(2).unwrap(), Some(winner));
11844        assert!(results
11845            .iter()
11846            .all(|result| result.as_ref().unwrap().applied_index == 3));
11847        assert_eq!(
11848            results[0].as_ref().unwrap().hash,
11849            results[1].as_ref().unwrap().hash
11850        );
11851    }
11852
11853    #[test]
11854    fn sql_group_commit_all_failed_calls_return_aligned_without_consensus() {
11855        let (_dir, runtime) = sql_test_runtime();
11856        let runtime = Arc::new(runtime);
11857        runtime
11858            .execute_sql(SqlCommand {
11859                request_id: "all-failed-schema".into(),
11860                statements: vec![SqlStatement {
11861                    sql: "CREATE TABLE all_failed(value TEXT UNIQUE)".into(),
11862                    parameters: vec![],
11863                }],
11864            })
11865            .unwrap();
11866        runtime
11867            .execute_sql(SqlCommand {
11868                request_id: "all-failed-existing".into(),
11869                statements: vec![SqlStatement {
11870                    sql: "INSERT INTO all_failed(value) VALUES ('existing')".into(),
11871                    parameters: vec![],
11872                }],
11873            })
11874            .unwrap();
11875
11876        let commit = runtime.lock_commit().unwrap();
11877        let mut workers = Vec::new();
11878        for id in 1..=2 {
11879            let worker_runtime = Arc::clone(&runtime);
11880            workers.push(std::thread::spawn(move || {
11881                worker_runtime
11882                    .execute_sql_batch(vec![SqlCommand {
11883                        request_id: format!("all-failed-{id}"),
11884                        statements: vec![SqlStatement {
11885                            sql: "INSERT INTO all_failed(value) VALUES ('existing')".into(),
11886                            parameters: vec![],
11887                        }],
11888                    }])
11889                    .unwrap()
11890            }));
11891            runtime
11892                .sql_group_commit
11893                .wait_for_pending_calls(id as usize, Duration::from_secs(5));
11894        }
11895        drop(commit);
11896
11897        for worker in workers {
11898            let results = worker.join().unwrap();
11899            assert_eq!(results.len(), 1);
11900            assert!(matches!(
11901                results[0],
11902                Err(NodeError::InvalidSqlStatement { .. })
11903            ));
11904        }
11905        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11906    }
11907
11908    #[test]
11909    fn sql_group_commit_lone_call_completes_after_the_bounded_collection_round() {
11910        let (_dir, runtime) = sql_test_runtime();
11911
11912        let result = runtime
11913            .execute_sql_batch(vec![SqlCommand {
11914                request_id: "lone-call".into(),
11915                statements: vec![SqlStatement {
11916                    sql: "CREATE TABLE lone_call(id INTEGER PRIMARY KEY)".into(),
11917                    parameters: vec![],
11918                }],
11919            }])
11920            .unwrap();
11921
11922        assert!(result[0].is_ok());
11923        let queue = runtime
11924            .sql_group_commit
11925            .state
11926            .lock()
11927            .unwrap_or_else(std::sync::PoisonError::into_inner);
11928        assert!(queue.pending.is_empty());
11929        assert!(!queue.leader_active);
11930    }
11931
11932    #[test]
11933    fn legacy_put_endpoint_commits_qwal_instead_of_raw_put_payload() {
11934        let (_dir, runtime) = sql_test_runtime();
11935
11936        let response = runtime.write("legacy-put", "key", "value").unwrap();
11937
11938        let entry = runtime
11939            .log_store()
11940            .read(response.applied_index)
11941            .unwrap()
11942            .unwrap();
11943        assert!(entry.payload.starts_with(QWAL_V3_MAGIC));
11944        assert!(!entry.payload.starts_with(b"put\t"));
11945        assert_eq!(
11946            runtime.read("key", ReadConsistency::Local).unwrap().value,
11947            Some("value".into())
11948        );
11949    }
11950
11951    #[test]
11952    fn sql_batch_preserves_order_commits_one_qwal_effect_and_retries_exactly() {
11953        let (_dir, runtime) = sql_test_runtime();
11954        runtime
11955            .execute_sql(SqlCommand {
11956                request_id: "schema".into(),
11957                statements: vec![SqlStatement {
11958                    sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
11959                        .into(),
11960                    parameters: vec![],
11961                }],
11962            })
11963            .unwrap();
11964        let commands = (1..=3)
11965            .map(|id| SqlCommand {
11966                request_id: format!("insert-{id}"),
11967                statements: vec![SqlStatement {
11968                    sql: "INSERT INTO batch_items(id, value) VALUES (?1, ?2)".into(),
11969                    parameters: vec![SqlValue::Integer(id), SqlValue::Text(format!("value-{id}"))],
11970                }],
11971            })
11972            .collect::<Vec<_>>();
11973
11974        let first = runtime.execute_sql_batch(commands.clone()).unwrap();
11975        let first_indices = first
11976            .iter()
11977            .map(|result| result.as_ref().unwrap().applied_index)
11978            .collect::<Vec<_>>();
11979        let log_index = runtime.log_store().last_index().unwrap();
11980        let replay = runtime.execute_sql_batch(commands).unwrap();
11981
11982        assert_eq!(first_indices, vec![2, 2, 2]);
11983        assert_eq!(
11984            first
11985                .iter()
11986                .map(|result| result.as_ref().unwrap().hash)
11987                .collect::<std::collections::HashSet<_>>()
11988                .len(),
11989            1
11990        );
11991        for index in 1..=2 {
11992            let entry = runtime.log_store().read(index).unwrap().unwrap();
11993            assert!(entry.payload.starts_with(QWAL_V3_MAGIC));
11994        }
11995        assert_eq!(
11996            replay
11997                .iter()
11998                .map(|result| result.as_ref().unwrap().applied_index)
11999                .collect::<Vec<_>>(),
12000            first_indices
12001        );
12002        assert_eq!(runtime.log_store().last_index().unwrap(), log_index);
12003    }
12004
12005    #[test]
12006    fn sql_effect_over_qlog_limit_is_resource_exhausted() {
12007        let (_dir, runtime) = sql_test_runtime();
12008        runtime
12009            .execute_sql(SqlCommand {
12010                request_id: "schema".into(),
12011                statements: vec![SqlStatement {
12012                    sql: "CREATE TABLE large_effect(value BLOB NOT NULL)".into(),
12013                    parameters: vec![],
12014                }],
12015            })
12016            .unwrap();
12017
12018        let error = runtime
12019            .execute_sql(SqlCommand {
12020                request_id: "large-effect".into(),
12021                statements: vec![SqlStatement {
12022                    sql: "INSERT INTO large_effect(value) VALUES (randomblob(700000))".into(),
12023                    parameters: vec![],
12024                }],
12025            })
12026            .unwrap_err();
12027
12028        assert!(matches!(error, NodeError::ResourceExhausted(_)));
12029        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
12030    }
12031
12032    #[test]
12033    fn sql_batch_isolates_request_conflict_from_unrelated_member() {
12034        let (_dir, runtime) = sql_test_runtime();
12035        runtime
12036            .execute_sql(SqlCommand {
12037                request_id: "schema".into(),
12038                statements: vec![SqlStatement {
12039                    sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
12040                        .into(),
12041                    parameters: vec![],
12042                }],
12043            })
12044            .unwrap();
12045        let insert = |request_id: &str, id: i64| SqlCommand {
12046            request_id: request_id.into(),
12047            statements: vec![SqlStatement {
12048                sql: "INSERT INTO batch_items(id, value) VALUES (?1, ?2)".into(),
12049                parameters: vec![SqlValue::Integer(id), SqlValue::Text(format!("value-{id}"))],
12050            }],
12051        };
12052
12053        let results = runtime
12054            .execute_sql_batch(vec![
12055                insert("same", 1),
12056                insert("same", 2),
12057                insert("other", 3),
12058            ])
12059            .unwrap();
12060
12061        assert!(results[0].is_ok());
12062        assert!(matches!(results[1], Err(NodeError::RequestConflict(_))));
12063        let conflict = results[1].as_ref().unwrap_err().classification();
12064        assert_eq!(conflict.code(), "request_conflict");
12065        assert_eq!(conflict.category(), ErrorCategory::Conflict);
12066        assert!(!conflict.retryable());
12067        assert!(results[2].is_ok());
12068        assert_eq!(
12069            results[0].as_ref().unwrap().applied_index,
12070            results[2].as_ref().unwrap().applied_index
12071        );
12072        assert!(runtime.is_ready());
12073    }
12074
12075    #[cfg(feature = "graph")]
12076    #[test]
12077    fn typed_batch_wrong_profile_is_rejected_before_log_attempt() {
12078        let (_dir, runtime) = graph_test_runtime();
12079        let command = SqlCommand {
12080            request_id: "wrong-profile".into(),
12081            statements: vec![SqlStatement {
12082                sql: "CREATE TABLE should_not_exist(id INTEGER PRIMARY KEY)".into(),
12083                parameters: vec![],
12084            }],
12085        };
12086
12087        let error = runtime.execute_sql_batch(vec![command]).unwrap_err();
12088
12089        assert!(matches!(
12090            error,
12091            NodeError::ExecutionProfileMismatch {
12092                expected: ExecutionProfile::Sqlite,
12093                actual: ExecutionProfile::Graph
12094            }
12095        ));
12096        assert_eq!(runtime.log_store().last_index().unwrap(), None);
12097    }
12098
12099    #[cfg(feature = "graph")]
12100    #[test]
12101    fn graph_query_timeout_returns_503_without_latching_readiness() {
12102        let (_dir, runtime) = graph_test_runtime();
12103        let graph = runtime.graph_materializer().unwrap();
12104        let graph_error = graph
12105            .query_read_only(
12106                "UNWIND range(1, 10000) AS x UNWIND range(1, 10000) AS y RETURN sum(x * y) AS total LIMIT 1",
12107                &std::collections::BTreeMap::new(),
12108                1,
12109                1024 * 1024,
12110                1,
12111            )
12112            .unwrap_err();
12113
12114        let response = node_error_response(runtime.map_graph_read_error(graph_error));
12115
12116        assert_eq!(
12117            response.status(),
12118            axum::http::StatusCode::SERVICE_UNAVAILABLE
12119        );
12120        assert!(runtime.is_ready());
12121        assert!(!runtime.is_fatal());
12122    }
12123
12124    #[cfg(feature = "graph")]
12125    #[test]
12126    fn graph_internal_error_returns_500_and_latches_readiness() {
12127        let (_dir, runtime) = graph_test_runtime();
12128
12129        let error =
12130            runtime.map_graph_read_error(rhiza_graph::Error::Ladybug("connection failed".into()));
12131        let response = node_error_response(error);
12132
12133        assert_eq!(
12134            response.status(),
12135            axum::http::StatusCode::INTERNAL_SERVER_ERROR
12136        );
12137        assert!(!runtime.is_ready());
12138        assert!(runtime.is_fatal());
12139    }
12140
12141    fn sql_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12142        sql_test_runtime_configured(None, None)
12143    }
12144
12145    fn sql_test_runtime_with_profiler(
12146        profiler: Option<SqlWriteProfiler>,
12147    ) -> (tempfile::TempDir, NodeRuntime) {
12148        sql_test_runtime_configured(profiler, None)
12149    }
12150
12151    fn sql_test_runtime_configured(
12152        profiler: Option<SqlWriteProfiler>,
12153        queue_capacity: Option<usize>,
12154    ) -> (tempfile::TempDir, NodeRuntime) {
12155        let dir = tempfile::tempdir().unwrap();
12156        let cluster_id = "node-unit-test";
12157        let mut config = NodeConfig::new_embedded(
12158            cluster_id,
12159            "n1",
12160            dir.path().join("node"),
12161            1,
12162            1,
12163            ["n1", "n2", "n3"],
12164        )
12165        .unwrap()
12166        .with_execution_profile(ExecutionProfile::Sqlite)
12167        .unwrap();
12168        if let Some(profiler) = profiler {
12169            config = config.with_sql_write_profiler(profiler);
12170        }
12171        if let Some(queue_capacity) = queue_capacity {
12172            config = config
12173                .with_sql_group_commit_queue_capacity(queue_capacity)
12174                .unwrap();
12175        }
12176        let consensus = Arc::new(
12177            ThreeNodeConsensus::from_recovered_tip(
12178                "rhiza:sql:node-unit-test",
12179                "n1",
12180                1,
12181                1,
12182                [
12183                    dir.path().join("recorders/n1"),
12184                    dir.path().join("recorders/n2"),
12185                    dir.path().join("recorders/n3"),
12186                ],
12187                1,
12188                LogHash::ZERO,
12189            )
12190            .unwrap(),
12191        );
12192        let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12193        (dir, runtime)
12194    }
12195
12196    #[cfg(feature = "graph")]
12197    fn graph_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12198        let dir = tempfile::tempdir().unwrap();
12199        let cluster_id = "node-unit-test";
12200        let config = NodeConfig::new_embedded(
12201            cluster_id,
12202            "n1",
12203            dir.path().join("node"),
12204            1,
12205            1,
12206            ["n1", "n2", "n3"],
12207        )
12208        .unwrap()
12209        .with_execution_profile(ExecutionProfile::Graph)
12210        .unwrap();
12211        let consensus = Arc::new(
12212            ThreeNodeConsensus::from_recovered_tip(
12213                "rhiza:graph:node-unit-test",
12214                "n1",
12215                1,
12216                1,
12217                [
12218                    dir.path().join("recorders/n1"),
12219                    dir.path().join("recorders/n2"),
12220                    dir.path().join("recorders/n3"),
12221                ],
12222                1,
12223                LogHash::ZERO,
12224            )
12225            .unwrap(),
12226        );
12227        let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12228        (dir, runtime)
12229    }
12230
12231    #[cfg(feature = "kv")]
12232    fn kv_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12233        let dir = tempfile::tempdir().unwrap();
12234        let cluster_id = "node-unit-test";
12235        let config = NodeConfig::new_embedded(
12236            cluster_id,
12237            "n1",
12238            dir.path().join("node"),
12239            1,
12240            1,
12241            ["n1", "n2", "n3"],
12242        )
12243        .unwrap()
12244        .with_execution_profile(ExecutionProfile::Kv)
12245        .unwrap();
12246        let consensus = Arc::new(
12247            ThreeNodeConsensus::from_recovered_tip(
12248                "rhiza:kv:node-unit-test",
12249                "n1",
12250                1,
12251                1,
12252                [
12253                    dir.path().join("recorders/n1"),
12254                    dir.path().join("recorders/n2"),
12255                    dir.path().join("recorders/n3"),
12256                ],
12257                1,
12258                LogHash::ZERO,
12259            )
12260            .unwrap(),
12261        );
12262        let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12263        (dir, runtime)
12264    }
12265}
12266
12267#[cfg(feature = "kv")]
12268fn largest_fitting_kv_batch_prefix(commands: &[KvCommandV1]) -> Option<(usize, Vec<u8>)> {
12269    if commands.len() < 3 {
12270        return None;
12271    }
12272    let mut lower = 2_usize;
12273    let mut upper = commands.len() - 1;
12274    let mut largest = None;
12275    while lower <= upper {
12276        let count = lower + (upper - lower) / 2;
12277        let payload = encode_replicated_kv_batch(&commands[..count])
12278            .expect("the validated KV batch prefix remains valid");
12279        if payload.len() <= MAX_COMMAND_BYTES {
12280            largest = Some((count, payload));
12281            lower = count + 1;
12282        } else {
12283            upper = count - 1;
12284        }
12285    }
12286    largest
12287}
12288
12289#[cfg(feature = "graph")]
12290fn validate_typed_batch_len(len: usize) -> Result<(), NodeError> {
12291    if (1..=MAX_WRITE_BATCH_MEMBERS).contains(&len) {
12292        Ok(())
12293    } else {
12294        Err(NodeError::InvalidRequest(format!(
12295            "write batch must contain 1..={MAX_WRITE_BATCH_MEMBERS} commands"
12296        )))
12297    }
12298}
12299
12300#[cfg(feature = "kv")]
12301fn validate_kv_batch_len(len: usize) -> Result<(), NodeError> {
12302    if (1..=MAX_KV_BATCH_MEMBERS).contains(&len) {
12303        Ok(())
12304    } else {
12305        Err(NodeError::InvalidRequest(format!(
12306            "KV write batch must contain 1..={MAX_KV_BATCH_MEMBERS} commands"
12307        )))
12308    }
12309}
12310
12311#[cfg(feature = "sql")]
12312fn validate_sql_batch_len(len: usize) -> Result<(), NodeError> {
12313    if (1..=MAX_TYPED_SQL_WRITE_BATCH_MEMBERS).contains(&len) {
12314        Ok(())
12315    } else {
12316        Err(NodeError::InvalidRequest(format!(
12317            "SQL write batch must contain 1..={MAX_TYPED_SQL_WRITE_BATCH_MEMBERS} commands"
12318        )))
12319    }
12320}
12321
12322fn validate_command_size(payload: &[u8]) -> Result<(), NodeError> {
12323    if payload.len() <= MAX_COMMAND_BYTES {
12324        Ok(())
12325    } else {
12326        Err(NodeError::InvalidRequest(format!(
12327            "command exceeds {MAX_COMMAND_BYTES} bytes"
12328        )))
12329    }
12330}
12331
12332#[cfg(feature = "sql")]
12333fn canonical_put(request_id: &str, key: &str, value: &str) -> Result<Vec<u8>, NodeError> {
12334    validate_field("request_id", request_id, MAX_REQUEST_ID_BYTES, false)?;
12335    validate_key(key)?;
12336    validate_field("value", value, MAX_VALUE_BYTES, true)?;
12337    let payload = encode_put_request(request_id, key, value)
12338        .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
12339    validate_command_size(&payload)?;
12340    Ok(payload)
12341}
12342
12343#[cfg(feature = "sql")]
12344fn encode_sql_command_with_index(command: &SqlCommand) -> Result<Vec<u8>, NodeError> {
12345    encode_sql_command(command).map_err(|error| {
12346        let message = error.to_string();
12347        match first_invalid_sql_statement(command, |prefix| encode_sql_command(prefix).is_err()) {
12348            Some(statement_index) => NodeError::InvalidSqlStatement {
12349                statement_index,
12350                message,
12351            },
12352            None => NodeError::InvalidRequest(message),
12353        }
12354    })
12355}
12356
12357#[cfg(feature = "sql")]
12358fn first_invalid_sql_statement(
12359    command: &SqlCommand,
12360    mut invalid: impl FnMut(&SqlCommand) -> bool,
12361) -> Option<usize> {
12362    if command.statements.is_empty() || command.statements.len() > MAX_SQL_STATEMENTS {
12363        return None;
12364    }
12365    (0..command.statements.len()).find(|statement_index| {
12366        let prefix = SqlCommand {
12367            request_id: command.request_id.clone(),
12368            statements: command.statements[..=*statement_index].to_vec(),
12369        };
12370        invalid(&prefix)
12371    })
12372}
12373
12374#[cfg(feature = "sql")]
12375fn validate_key(key: &str) -> Result<(), NodeError> {
12376    validate_field("key", key, MAX_KEY_BYTES, false)
12377}
12378
12379#[cfg(feature = "sql")]
12380fn validate_field(
12381    name: &str,
12382    value: &str,
12383    max_bytes: usize,
12384    allow_empty: bool,
12385) -> Result<(), NodeError> {
12386    if !allow_empty && value.is_empty() {
12387        return Err(NodeError::InvalidRequest(format!(
12388            "{name} must not be empty"
12389        )));
12390    }
12391    if value.len() > max_bytes {
12392        return Err(NodeError::InvalidRequest(format!(
12393            "{name} exceeds {max_bytes} bytes"
12394        )));
12395    }
12396    if value.contains('\t') {
12397        return Err(NodeError::InvalidRequest(format!(
12398            "{name} must not contain a tab"
12399        )));
12400    }
12401    Ok(())
12402}
12403
12404#[cfg(feature = "sql")]
12405fn write_response(outcome: RequestOutcome) -> WriteResponse {
12406    WriteResponse {
12407        applied_index: outcome.original_log_index(),
12408        hash: outcome.original_log_hash(),
12409    }
12410}
12411
12412fn reconcile_local_storage(
12413    config: &NodeConfig,
12414    log_store: &FileLogStore,
12415    materializer: &Materializer,
12416) -> Result<(), NodeError> {
12417    let mut log_state = log_store
12418        .logical_state()
12419        .map_err(|error| NodeError::Storage(error.to_string()))?;
12420    let mut log_last_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
12421    let applied_index = materializer
12422        .applied_index()
12423        .map_err(|error| NodeError::Storage(error.to_string()))?;
12424    let applied_hash = materializer
12425        .applied_hash()
12426        .map_err(|error| NodeError::Storage(error.to_string()))?;
12427    let mut materializer_configuration = materializer
12428        .configuration_state()
12429        .map_err(|error| NodeError::Storage(error.to_string()))?;
12430
12431    if let Some(anchor) = &log_state.anchor {
12432        if anchor.recovery_generation() != config.recovery_generation {
12433            return Err(NodeError::Reconciliation(format!(
12434                "qlog anchor recovery generation {} differs from runtime generation {}",
12435                anchor.recovery_generation(),
12436                config.recovery_generation
12437            )));
12438        }
12439        if applied_index < anchor.compacted().index() {
12440            return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())));
12441        }
12442    }
12443    if applied_index > log_last_index {
12444        let entries: Option<Vec<LogEntry>> = match materializer {
12445            #[cfg(feature = "sql")]
12446            Materializer::Sql(sql) => Some(
12447                sql.embedded_log_entries(log_last_index.saturating_add(1), applied_index)
12448                    .map_err(|error| NodeError::Reconciliation(error.to_string()))?,
12449            ),
12450            #[cfg(feature = "kv")]
12451            Materializer::Kv(kv) => Some(
12452                kv.embedded_log_entries(log_last_index.saturating_add(1), applied_index)
12453                    .map_err(|error| NodeError::Reconciliation(error.to_string()))?,
12454            ),
12455            #[cfg(feature = "graph")]
12456            Materializer::Graph(_) => None,
12457            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
12458            Materializer::Unavailable => None,
12459        };
12460        if let Some(entries) = entries {
12461            log_store
12462                .append_batch(&entries)
12463                .map_err(|error| NodeError::Storage(error.to_string()))?;
12464            log_state = log_store
12465                .logical_state()
12466                .map_err(|error| NodeError::Storage(error.to_string()))?;
12467            log_last_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
12468        }
12469    }
12470    if applied_index > log_last_index {
12471        return Err(NodeError::Reconciliation(format!(
12472            "{} materializer is ahead at {applied_index}, qlog ends at {log_last_index}",
12473            materializer.profile()
12474        )));
12475    }
12476    if applied_index == 0 {
12477        if applied_hash != LogHash::ZERO {
12478            return Err(NodeError::Reconciliation(format!(
12479                "{} materializer genesis hash is not zero",
12480                materializer.profile()
12481            )));
12482        }
12483    } else if !log_state.anchor.as_ref().is_some_and(|anchor| {
12484        applied_index == anchor.compacted().index() && applied_hash == anchor.compacted().hash()
12485    }) {
12486        let entry = log_store
12487            .read(applied_index)
12488            .map_err(|error| NodeError::Storage(error.to_string()))?
12489            .ok_or_else(|| {
12490                NodeError::Reconciliation(format!(
12491                    "qlog prefix is missing {} materializer index {applied_index}",
12492                    materializer.profile()
12493                ))
12494            })?;
12495        validate_entry_envelope(config, &entry, applied_index, entry.prev_hash)?;
12496        if entry.hash != applied_hash {
12497            return Err(NodeError::Reconciliation(format!(
12498                "{} materializer hash diverges from qlog at index {applied_index}",
12499                materializer.profile()
12500            )));
12501        }
12502    }
12503
12504    let mut expected_prev_hash = applied_hash;
12505    for index in (applied_index + 1)..=log_last_index {
12506        let entry = log_store
12507            .read(index)
12508            .map_err(|error| NodeError::Storage(error.to_string()))?
12509            .ok_or_else(|| {
12510                NodeError::Reconciliation(format!("qlog prefix is missing index {index}"))
12511            })?;
12512        match &materializer_configuration {
12513            Some(configuration) => {
12514                validate_runtime_entry(config, configuration, &entry, index, expected_prev_hash)?
12515            }
12516            None => validate_entry_envelope(config, &entry, index, expected_prev_hash)?,
12517        };
12518        materializer
12519            .apply_entry(&entry)
12520            .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12521        materializer_configuration = materializer
12522            .configuration_state()
12523            .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12524        expected_prev_hash = entry.hash;
12525    }
12526    let log_configuration = log_store
12527        .configuration_state()
12528        .map_err(|error| NodeError::Storage(error.to_string()))?;
12529    if materializer_configuration
12530        .as_ref()
12531        .is_some_and(|configuration| configuration != &log_configuration)
12532    {
12533        return Err(NodeError::Reconciliation(format!(
12534            "qlog and {} materializer configuration states disagree",
12535            materializer.profile()
12536        )));
12537    }
12538    Ok(())
12539}
12540
12541fn recover_peer_candidates(
12542    config: &NodeConfig,
12543    consensus: &ThreeNodeConsensus,
12544    log_store: &FileLogStore,
12545    materializer: &Materializer,
12546    peer_candidates: &[&dyn LogPeer],
12547) -> Result<(), NodeError> {
12548    for peer in peer_candidates {
12549        let (last_index, last_hash) = static_log_tip(log_store)?;
12550        let candidates = match peer.fetch_log(FetchLogRequest {
12551            from_index: last_index.saturating_add(1),
12552            max_entries: MAX_FETCH_ENTRIES,
12553        }) {
12554            Ok(response) => validate_fetched_entries_with_configuration(
12555                last_index.saturating_add(1),
12556                last_hash,
12557                &config.cluster_id,
12558                config.epoch,
12559                log_store
12560                    .configuration_state()
12561                    .map_err(|error| NodeError::Storage(error.to_string()))?,
12562                response.entries,
12563            )
12564            .map_err(|error| {
12565                NodeError::Reconciliation(format!("peer candidate validation failed: {error}"))
12566            })?,
12567            Err(FetchLogError::Transport { .. }) => continue,
12568            Err(FetchLogError::SnapshotRequired { anchor }) => {
12569                return Err(NodeError::SnapshotRequired(anchor));
12570            }
12571            Err(error) => {
12572                return Err(NodeError::Reconciliation(format!(
12573                    "peer candidate validation failed: {error}"
12574                )));
12575            }
12576        };
12577
12578        let mut expected_index = last_index.checked_add(1).ok_or_else(|| {
12579            NodeError::Reconciliation("qlog index is exhausted during peer catch-up".into())
12580        })?;
12581        let mut expected_prev_hash = last_hash;
12582        for candidate in candidates {
12583            match consensus
12584                .inspect_decision_at(expected_index, expected_prev_hash)
12585                .map_err(startup_consensus_error)?
12586            {
12587                DecisionInspection::Committed(committed) if committed == candidate => {
12588                    persist_startup_entry(
12589                        config,
12590                        log_store,
12591                        materializer,
12592                        &candidate,
12593                        expected_index,
12594                        expected_prev_hash,
12595                    )?;
12596                    expected_prev_hash = candidate.hash;
12597                    expected_index = expected_index.checked_add(1).ok_or_else(|| {
12598                        NodeError::Reconciliation(
12599                            "qlog index is exhausted during peer catch-up".into(),
12600                        )
12601                    })?;
12602                }
12603                DecisionInspection::Committed(_) => {
12604                    return Err(NodeError::Reconciliation(format!(
12605                        "peer candidate at index {expected_index} differs from committed decision"
12606                    )));
12607                }
12608                DecisionInspection::Unavailable => {
12609                    return Err(NodeError::Unavailable(format!(
12610                        "decision inspection unavailable for peer candidate at index {expected_index}"
12611                    )));
12612                }
12613                DecisionInspection::Empty | DecisionInspection::Pending => {
12614                    return Err(NodeError::Reconciliation(format!(
12615                        "peer candidate at index {expected_index} is not committed"
12616                    )));
12617                }
12618            }
12619        }
12620    }
12621    Ok(())
12622}
12623
12624fn recover_startup_decisions(
12625    config: &NodeConfig,
12626    consensus: &ThreeNodeConsensus,
12627    log_store: &FileLogStore,
12628    materializer: &Materializer,
12629) -> Result<(), NodeError> {
12630    for _ in 0..MAX_STARTUP_RECOVERY_ENTRIES {
12631        let (last_index, last_hash) = static_log_tip(log_store)?;
12632        let slot = last_index.checked_add(1).ok_or_else(|| {
12633            NodeError::Reconciliation("qlog index is exhausted during startup".into())
12634        })?;
12635        match consensus
12636            .inspect_decision_at(slot, last_hash)
12637            .map_err(startup_consensus_error)?
12638        {
12639            DecisionInspection::Committed(entry) => {
12640                persist_startup_entry(config, log_store, materializer, &entry, slot, last_hash)?;
12641            }
12642            DecisionInspection::Pending => {
12643                let entry = consensus
12644                    .propose_at(
12645                        slot,
12646                        last_hash,
12647                        Command::new(CommandKind::ReadBarrier, Vec::new()),
12648                    )
12649                    .map_err(startup_consensus_error)?;
12650                persist_startup_entry(config, log_store, materializer, &entry, slot, last_hash)?;
12651            }
12652            DecisionInspection::Empty => return Ok(()),
12653            DecisionInspection::Unavailable => {
12654                return Err(NodeError::Unavailable(
12655                    "decision inspection unavailable during startup".into(),
12656                ));
12657            }
12658        }
12659    }
12660    Err(NodeError::Reconciliation(format!(
12661        "startup recovery exceeded {MAX_STARTUP_RECOVERY_ENTRIES} entries"
12662    )))
12663}
12664
12665fn persist_startup_entry(
12666    config: &NodeConfig,
12667    log_store: &FileLogStore,
12668    materializer: &Materializer,
12669    entry: &LogEntry,
12670    expected_index: LogIndex,
12671    expected_prev_hash: LogHash,
12672) -> Result<(), NodeError> {
12673    let configuration_state = log_store
12674        .configuration_state()
12675        .map_err(|error| NodeError::Storage(error.to_string()))?;
12676    validate_runtime_entry(
12677        config,
12678        &configuration_state,
12679        entry,
12680        expected_index,
12681        expected_prev_hash,
12682    )?;
12683    log_store
12684        .append(entry)
12685        .map_err(|error| NodeError::Storage(error.to_string()))?;
12686    materializer
12687        .apply_entry(entry)
12688        .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12689    Ok(())
12690}
12691
12692fn static_log_tip(log_store: &FileLogStore) -> Result<(LogIndex, LogHash), NodeError> {
12693    Ok(log_store
12694        .logical_state()
12695        .map_err(|error| NodeError::Storage(error.to_string()))?
12696        .tip
12697        .map_or((0, LogHash::ZERO), |tip| (tip.index(), tip.hash())))
12698}
12699
12700fn validate_runtime_entry(
12701    config: &NodeConfig,
12702    configuration_state: &ConfigurationState,
12703    entry: &LogEntry,
12704    expected_index: LogIndex,
12705    expected_prev_hash: LogHash,
12706) -> Result<(), NodeError> {
12707    validate_entry_envelope(config, entry, expected_index, expected_prev_hash)?;
12708    validate_profile_entry_shape(config.execution_profile(), entry)
12709        .map_err(NodeError::Invariant)?;
12710    configuration_state
12711        .validate_entry(entry)
12712        .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12713    Ok(())
12714}
12715
12716fn validate_entry_envelope(
12717    config: &NodeConfig,
12718    entry: &LogEntry,
12719    expected_index: LogIndex,
12720    expected_prev_hash: LogHash,
12721) -> Result<(), NodeError> {
12722    if entry.index != expected_index {
12723        return Err(NodeError::Reconciliation(format!(
12724            "expected decision index {expected_index}, got {}",
12725            entry.index
12726        )));
12727    }
12728    if entry.cluster_id != config.cluster_id || entry.epoch != config.epoch {
12729        return Err(NodeError::Reconciliation(format!(
12730            "decision {} has a foreign identity",
12731            entry.index
12732        )));
12733    }
12734    if entry.prev_hash != expected_prev_hash {
12735        return Err(NodeError::Reconciliation(format!(
12736            "decision {} has a conflicting predecessor",
12737            entry.index
12738        )));
12739    }
12740    if entry.recompute_hash() != entry.hash {
12741        return Err(NodeError::Reconciliation(format!(
12742            "decision {} has an invalid hash",
12743            entry.index
12744        )));
12745    }
12746    validate_entry_shape(entry).map_err(NodeError::Invariant)
12747}
12748
12749fn validate_entry_shape(entry: &LogEntry) -> Result<(), String> {
12750    match entry.entry_type {
12751        EntryType::Command if entry.payload.len() <= MAX_COMMAND_BYTES => Ok(()),
12752        EntryType::Command => Err(format!("command exceeds {MAX_COMMAND_BYTES} bytes")),
12753        EntryType::Noop if entry.payload.is_empty() => Ok(()),
12754        EntryType::Noop => Err("Noop payload must be empty".into()),
12755        EntryType::ConfigChange => ConfigChange::recognize(&StoredCommand::new(
12756            EntryType::ConfigChange,
12757            entry.payload.clone(),
12758        ))
12759        .map(|_| ())
12760        .map_err(|error| error.to_string()),
12761        other => Err(format!("unsupported runtime entry type {other:?}")),
12762    }
12763}
12764
12765pub(crate) fn validate_profile_entry_shape(
12766    _profile: ExecutionProfile,
12767    entry: &LogEntry,
12768) -> Result<(), String> {
12769    validate_entry_shape(entry)?;
12770    #[cfg(feature = "sql")]
12771    if _profile == ExecutionProfile::Sqlite && entry.entry_type == EntryType::Command {
12772        decode_qwal_v3(&entry.payload)
12773            .map_err(|error| format!("SQLite command is not canonical QWAL v3: {error}"))?;
12774    }
12775    Ok(())
12776}
12777
12778fn startup_consensus_error(error: rhiza_quepaxa::Error) -> NodeError {
12779    match error {
12780        rhiza_quepaxa::Error::NoQuorum
12781        | rhiza_quepaxa::Error::CommandUnavailable
12782        | rhiza_quepaxa::Error::Io(_) => NodeError::Unavailable(error.to_string()),
12783        other => NodeError::Reconciliation(other.to_string()),
12784    }
12785}
12786
12787#[cfg(feature = "sql")]
12788#[derive(Clone, Debug, Eq, PartialEq)]
12789pub struct E2eConfig {
12790    pub data_dir: PathBuf,
12791    pub object_store: ObjStoreConfig,
12792    pub cluster_id: String,
12793    pub node_id: String,
12794}
12795
12796#[cfg(feature = "sql")]
12797#[derive(Clone, Debug, Eq, PartialEq)]
12798pub struct E2eReport {
12799    pub applied_index: LogIndex,
12800    pub restored_value: String,
12801    pub object_keys: Vec<String>,
12802}
12803
12804#[cfg(feature = "sql")]
12805pub async fn run_e2e(config: E2eConfig) -> Result<E2eReport, Box<dyn std::error::Error>> {
12806    let sqlite_dir = config.data_dir.join("sqlite");
12807    let log_dir = config.data_dir.join("consensus").join("log");
12808    ensure_fresh_e2e_data_dir(&config.data_dir, &sqlite_dir, &log_dir)?;
12809
12810    fs::create_dir_all(&config.data_dir)?;
12811    let db_path = sqlite_dir.join("db.sqlite");
12812    let restore_path = config.data_dir.join("restore").join("db.sqlite");
12813    let db = SqliteStateMachine::open(&db_path, &config.cluster_id, &config.node_id, 1, 1)?;
12814    let recorder_dir = config.data_dir.join("consensus").join("recorder");
12815    let consensus = ThreeNodeConsensus::new(
12816        &config.cluster_id,
12817        &config.node_id,
12818        1,
12819        1,
12820        [
12821            recorder_dir.join("node-1"),
12822            recorder_dir.join("node-2"),
12823            recorder_dir.join("node-3"),
12824        ],
12825    )?;
12826    let base_request = canonical_put("e2e-base", "alpha", "bravo")?;
12827    let base_effect = db.prepare_put_effect(
12828        "e2e-base",
12829        "alpha",
12830        "bravo",
12831        &base_request,
12832        0,
12833        LogHash::ZERO,
12834    )?;
12835    let base_entry = consensus.propose(Command::new(CommandKind::Deterministic, base_effect))?;
12836    db.apply_entry(&base_entry)?;
12837    let snapshot = db.create_snapshot(base_entry.index)?;
12838
12839    let tail_request = canonical_put("e2e-tail", "alpha", "charlie")?;
12840    let tail_effect = db.prepare_put_effect(
12841        "e2e-tail",
12842        "alpha",
12843        "charlie",
12844        &tail_request,
12845        base_entry.index,
12846        base_entry.hash,
12847    )?;
12848    let tail_entry = consensus.propose(Command::new(CommandKind::Deterministic, tail_effect))?;
12849    let segment_path = write_segment_file(&log_dir, std::slice::from_ref(&tail_entry))?;
12850    let segment = rhiza_log::SegmentFile::new(
12851        IndexRange::new(tail_entry.index, tail_entry.index)?,
12852        fs::read(&segment_path)?,
12853    );
12854    db.apply_entry(&tail_entry)?;
12855
12856    let local_archive = matches!(&config.object_store, ObjStoreConfig::Local { .. });
12857    let store = ObjStore::new(config.object_store)?;
12858    let archive = if local_archive {
12859        rhiza_archive::ObjectArchiveStore::new_for_single_process(
12860            store.clone(),
12861            config.cluster_id.clone(),
12862        )
12863    } else {
12864        rhiza_archive::ObjectArchiveStore::new(store.clone(), config.cluster_id.clone())?
12865    };
12866
12867    let segment_record = archive.publish_segment(tail_entry.epoch, &segment).await?;
12868    let snapshot_record = archive.publish_snapshot(&snapshot).await?;
12869    let (mut archive_manifest, expected_manifest_version) = match archive.load_manifest().await? {
12870        Some(loaded) => (loaded.manifest().clone(), Some(loaded.version().clone())),
12871        None => (
12872            rhiza_archive::ArchiveManifest::new(config.cluster_id.clone()),
12873            None,
12874        ),
12875    };
12876    archive_manifest.set_latest_snapshot(snapshot_record);
12877    archive_manifest.add_segment(segment_record);
12878    archive
12879        .publish_manifest(&archive_manifest, expected_manifest_version)
12880        .await?;
12881
12882    let loaded_manifest = archive
12883        .load_manifest()
12884        .await?
12885        .ok_or("published archive manifest is missing")?;
12886    if loaded_manifest.manifest() != &archive_manifest {
12887        return Err("reloaded archive manifest did not match the published manifest".into());
12888    }
12889    let archived_snapshot = loaded_manifest
12890        .manifest()
12891        .latest_snapshot()
12892        .ok_or("archive manifest is missing its snapshot")?;
12893    let archived_segment = loaded_manifest
12894        .manifest()
12895        .segments()
12896        .iter()
12897        .find(|record| {
12898            record.start_index() == tail_entry.index && record.end_index() == tail_entry.index
12899        })
12900        .ok_or("archive manifest is missing its post-snapshot segment")?;
12901
12902    let downloaded_segment = archive.download_segment(archived_segment).await?;
12903    let downloaded_entries = decode_segment_for_cluster(&downloaded_segment, &config.cluster_id)?;
12904    if downloaded_entries.as_slice() != std::slice::from_ref(&tail_entry) {
12905        return Err("downloaded qlog segment did not match written entry".into());
12906    }
12907    let downloaded_snapshot = rhiza_core::Snapshot::new(
12908        archived_snapshot.manifest().clone(),
12909        archive.download_snapshot(archived_snapshot).await?,
12910    );
12911    restore_snapshot_file(&restore_path, &downloaded_snapshot, &config.node_id)?;
12912    let restored_db = SqliteStateMachine::open_existing(&restore_path)?;
12913    if restored_db.get_value("alpha")?.as_deref() != Some("bravo") {
12914        return Err("restored base snapshot is missing alpha=bravo".into());
12915    }
12916    for entry in &downloaded_entries {
12917        restored_db.apply_entry(entry)?;
12918    }
12919    let restored_value = restored_db
12920        .get_value("alpha")?
12921        .ok_or("restored SQLite state is missing alpha")?;
12922    let applied_index = restored_db.applied_index_value()?;
12923    if applied_index != tail_entry.index || restored_value != "charlie" {
12924        return Err("restored SQLite state did not include the archived log tail".into());
12925    }
12926    let object_keys = store.list(&format!("rhiza/{}", config.cluster_id)).await?;
12927
12928    Ok(E2eReport {
12929        applied_index,
12930        restored_value,
12931        object_keys,
12932    })
12933}
12934
12935#[cfg(feature = "sql")]
12936fn ensure_fresh_e2e_data_dir(
12937    data_dir: &std::path::Path,
12938    sqlite_dir: &std::path::Path,
12939    log_dir: &std::path::Path,
12940) -> Result<(), Box<dyn std::error::Error>> {
12941    if directory_has_entries(sqlite_dir)? || directory_has_entries(log_dir)? {
12942        return Err(format!(
12943            "e2e data directory is not fresh: prior SQLite/qlog data exists in {}",
12944            data_dir.display()
12945        )
12946        .into());
12947    }
12948    Ok(())
12949}
12950
12951#[cfg(feature = "sql")]
12952fn directory_has_entries(path: &std::path::Path) -> Result<bool, std::io::Error> {
12953    match fs::read_dir(path) {
12954        Ok(mut entries) => entries.next().transpose().map(|entry| entry.is_some()),
12955        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
12956        Err(error) => Err(error),
12957    }
12958}