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.configuration_state() != anchor.configuration_state()
8510            || manifest.index() != anchor.compacted().index()
8511            || manifest.applied_hash() != anchor.compacted().hash()
8512            || manifest.snapshot_id() != anchor.snapshot().snapshot_id()
8513            || manifest.executor_fingerprint() != anchor.snapshot().executor_fingerprint()
8514            || publication_digest != anchor.snapshot().digest()
8515            || publication.size_bytes() != anchor.snapshot().size_bytes()
8516            || LogHash::digest(&[snapshot.db_bytes()]) != anchor.snapshot().digest()
8517            || snapshot.db_bytes().len() as u64 != anchor.snapshot().size_bytes()
8518        {
8519            return Err(NodeError::Reconciliation(
8520                "published snapshot does not match the runtime recovery anchor".into(),
8521            ));
8522        }
8523        Ok(VerifiedSnapshotPublication {
8524            anchor: anchor.clone(),
8525        })
8526    }
8527
8528    #[cfg(feature = "sql")]
8529    pub fn compact_log(&self, publication: &VerifiedSnapshotPublication) -> Result<(), NodeError> {
8530        let _commit = self.lock_commit()?;
8531        self.ensure_ready()?;
8532        let applied_index = self.applied_index()?;
8533        let applied_hash = self.applied_hash()?;
8534        let anchor = &publication.anchor;
8535        if anchor.cluster_id() != self.config.cluster_id
8536            || anchor.epoch() != self.config.epoch
8537            || anchor.config_id() != self.config.config_id()
8538            || anchor.recovery_generation() != self.config.recovery_generation
8539            || anchor.compacted().index() != applied_index
8540            || anchor.compacted().hash() != applied_hash
8541        {
8542            return Err(NodeError::Reconciliation(
8543                "verified snapshot anchor does not match the current applied entry".into(),
8544            ));
8545        }
8546        self.log_store
8547            .compact_prefix(anchor)
8548            .map_err(|error| NodeError::Storage(error.to_string()))?;
8549        self.compact_embedded_log_before(anchor.compacted().index())
8550    }
8551
8552    pub fn is_ready(&self) -> bool {
8553        self.ready.load(Ordering::Acquire)
8554            && !self.fatal.load(Ordering::Acquire)
8555            && !self.checkpointing.load(Ordering::Acquire)
8556    }
8557
8558    pub fn is_fatal(&self) -> bool {
8559        self.fatal.load(Ordering::Acquire)
8560    }
8561
8562    pub fn fatal_reason(&self) -> Option<String> {
8563        self.fatal_reason
8564            .lock()
8565            .ok()
8566            .and_then(|reason| reason.clone())
8567    }
8568
8569    #[cfg(feature = "sql")]
8570    fn read_local(
8571        &self,
8572        key: &str,
8573        required_index: Option<LogIndex>,
8574    ) -> Result<ReadResponse, NodeError> {
8575        self.ensure_ready()?;
8576        let sqlite = self.lock_sqlite()?;
8577        let (applied_index, hash) = sqlite
8578            .applied_tip_value()
8579            .map_err(|error| self.map_sqlite_error(error))?;
8580        if required_index.is_some_and(|required| applied_index < required) {
8581            return Err(NodeError::Unavailable(format!(
8582                "local applied index {applied_index} has not reached {}",
8583                required_index.expect("checked above")
8584            )));
8585        }
8586        let value = sqlite
8587            .get_value(key)
8588            .map_err(|error| self.map_sqlite_error(error))?;
8589        Ok(ReadResponse {
8590            value,
8591            applied_index,
8592            hash,
8593        })
8594    }
8595
8596    #[cfg(feature = "graph")]
8597    fn get_graph_document_local(
8598        &self,
8599        id: &str,
8600        required_index: Option<LogIndex>,
8601    ) -> Result<GraphReadResponse, NodeError> {
8602        self.ensure_ready()?;
8603        let graph = self.graph_materializer()?;
8604        let (value, applied_index, hash) = graph
8605            .get_document_with_tip(id)
8606            .map_err(|error| self.map_graph_read_error(error))?;
8607        if required_index.is_some_and(|required| applied_index < required) {
8608            return Err(NodeError::Unavailable(format!(
8609                "local applied index {applied_index} has not reached {}",
8610                required_index.expect("checked above")
8611            )));
8612        }
8613        Ok(GraphReadResponse {
8614            value,
8615            applied_index,
8616            hash,
8617        })
8618    }
8619
8620    #[cfg(feature = "graph")]
8621    fn query_graph_local(
8622        &self,
8623        statement: &str,
8624        parameters: &BTreeMap<String, GraphParameterValue>,
8625        required_index: Option<LogIndex>,
8626        max_rows: u32,
8627    ) -> Result<GraphQueryResult, NodeError> {
8628        self.ensure_ready()?;
8629        let graph = self.graph_materializer()?;
8630        let result = graph
8631            .query_read_only(
8632                statement,
8633                parameters,
8634                usize::try_from(max_rows).expect("u32 fits usize"),
8635                MAX_GRAPH_RESULT_BYTES,
8636                GRAPH_QUERY_TIMEOUT_MS,
8637            )
8638            .map_err(|error| self.map_graph_read_error(error))?;
8639        if required_index.is_some_and(|required| result.applied_index < required) {
8640            return Err(NodeError::Unavailable(format!(
8641                "local applied index {} has not reached {}",
8642                result.applied_index,
8643                required_index.expect("checked above")
8644            )));
8645        }
8646        Ok(result)
8647    }
8648
8649    #[cfg(feature = "kv")]
8650    fn get_kv_local(
8651        &self,
8652        key: &[u8],
8653        required_index: Option<LogIndex>,
8654    ) -> Result<KvReadResponse, NodeError> {
8655        self.ensure_ready()?;
8656        let kv = self.kv_materializer()?;
8657        let result = kv
8658            .get_with_tip(key)
8659            .map_err(|error| self.map_kv_read_error(error))?;
8660        let (value, tip) = result.into_parts();
8661        let applied_index = tip.applied_index();
8662        if required_index.is_some_and(|required| applied_index < required) {
8663            return Err(NodeError::Unavailable(format!(
8664                "local applied index {applied_index} has not reached {}",
8665                required_index.expect("checked above")
8666            )));
8667        }
8668        Ok(KvReadResponse {
8669            value,
8670            applied_index,
8671            hash: tip.applied_hash(),
8672        })
8673    }
8674
8675    #[cfg(feature = "kv")]
8676    fn scan_kv_range_local(
8677        &self,
8678        start: &[u8],
8679        end: Option<&[u8]>,
8680        limit: usize,
8681        cursor: Option<&[u8]>,
8682        required_index: Option<LogIndex>,
8683    ) -> Result<KvScanResult, NodeError> {
8684        self.ensure_ready()?;
8685        let kv = self.kv_materializer()?;
8686        let result = kv
8687            .scan_range(start, end, limit, cursor)
8688            .map_err(|error| self.map_kv_read_error(error))?;
8689        validate_kv_scan_required_index(&result, required_index)?;
8690        Ok(result)
8691    }
8692
8693    #[cfg(feature = "kv")]
8694    fn scan_kv_prefix_local(
8695        &self,
8696        prefix: &[u8],
8697        limit: usize,
8698        cursor: Option<&[u8]>,
8699        required_index: Option<LogIndex>,
8700    ) -> Result<KvScanResult, NodeError> {
8701        self.ensure_ready()?;
8702        let kv = self.kv_materializer()?;
8703        let result = kv
8704            .scan_prefix(prefix, limit, cursor)
8705            .map_err(|error| self.map_kv_read_error(error))?;
8706        validate_kv_scan_required_index(&result, required_index)?;
8707        Ok(result)
8708    }
8709
8710    #[cfg(feature = "sql")]
8711    fn query_sql_local(
8712        &self,
8713        statement: &SqlStatement,
8714        required_index: Option<LogIndex>,
8715        max_rows: u32,
8716    ) -> Result<SqlQueryResponse, NodeError> {
8717        self.ensure_ready()?;
8718        let sqlite = self.lock_sqlite()?;
8719        let (applied_index, hash) = sqlite
8720            .applied_tip_value()
8721            .map_err(|error| self.map_sqlite_error(error))?;
8722        if required_index.is_some_and(|required| applied_index < required) {
8723            return Err(NodeError::Unavailable(format!(
8724                "local applied index {applied_index} has not reached {}",
8725                required_index.expect("checked above")
8726            )));
8727        }
8728        let SqlQueryResult { columns, rows } = sqlite
8729            .query_sql(
8730                statement,
8731                usize::try_from(max_rows).expect("u32 fits usize"),
8732                MAX_SQL_RESULT_BYTES,
8733            )
8734            .map_err(|error| match error {
8735                rhiza_sql::Error::ResourceExhausted(message) => {
8736                    NodeError::ResourceExhausted(message)
8737                }
8738                other => NodeError::InvalidSqlStatement {
8739                    statement_index: 0,
8740                    message: other.to_string(),
8741                },
8742            })?;
8743        Ok(SqlQueryResponse {
8744            columns,
8745            rows,
8746            applied_index,
8747            hash,
8748        })
8749    }
8750
8751    fn establish_read_barrier(&self) -> Result<LogAnchor, NodeError> {
8752        let participant = self.read_barriers.join().map_err(|error| match error {
8753            NodeError::Invariant(_) => self.latch(error),
8754            other => other,
8755        })?;
8756        let Some(mut publication) = participant.publication() else {
8757            return participant.wait(&self.operation_cancelled);
8758        };
8759
8760        let result = (|| {
8761            publication.wait_turn(&self.operation_cancelled)?;
8762            // The public read path must not own this mutex before entering the
8763            // coalescer. The generation cutoff happens only after the round
8764            // leader owns commit, immediately before consensus begins.
8765            let _commit = self.lock_commit()?;
8766            publication.start(&self.operation_cancelled)?;
8767            self.ensure_ready()?;
8768            self.commit_read_barrier_locked()
8769        })();
8770        publication.publish(result.clone());
8771        result
8772    }
8773
8774    #[cfg(any(feature = "graph", feature = "kv"))]
8775    fn validate_read_barrier_before_snapshot(&self, anchor: LogAnchor) -> Result<(), NodeError> {
8776        {
8777            let _commit = self.lock_commit()?;
8778            self.ensure_ready()?;
8779            self.ensure_writes_active()?;
8780            self.validate_read_barrier_qlog_descendant_locked(anchor)?;
8781        }
8782        Ok(())
8783    }
8784
8785    #[cfg(any(feature = "graph", feature = "kv"))]
8786    fn validate_read_barrier_snapshot(
8787        &self,
8788        anchor: LogAnchor,
8789        observed: LogAnchor,
8790    ) -> Result<(), NodeError> {
8791        if observed.index() < anchor.index() {
8792            return Err(NodeError::Unavailable(format!(
8793                "read snapshot tip {} precedes read barrier {}",
8794                observed.index(),
8795                anchor.index()
8796            )));
8797        }
8798        if observed.index() == anchor.index() && observed.hash() != anchor.hash() {
8799            return Err(self.latch(NodeError::Invariant(
8800                "read snapshot tip hash differs from the read barrier anchor".into(),
8801            )));
8802        }
8803        Ok(())
8804    }
8805
8806    #[cfg(feature = "sql")]
8807    fn validate_read_barrier_descendant_locked(&self, anchor: LogAnchor) -> Result<(), NodeError> {
8808        let (applied_index, applied_hash) = self.ensure_materialized_tip()?;
8809        self.validate_read_barrier_descendant_from_tip(
8810            anchor,
8811            LogAnchor::new(applied_index, applied_hash),
8812            "materialized",
8813        )
8814    }
8815
8816    #[cfg(any(feature = "graph", feature = "kv"))]
8817    fn validate_read_barrier_qlog_descendant_locked(
8818        &self,
8819        anchor: LogAnchor,
8820    ) -> Result<(), NodeError> {
8821        let (qlog_index, qlog_hash) = self.durable_tip()?;
8822        self.validate_read_barrier_descendant_from_tip(
8823            anchor,
8824            LogAnchor::new(qlog_index, qlog_hash),
8825            "qlog",
8826        )
8827    }
8828
8829    fn validate_read_barrier_descendant_from_tip(
8830        &self,
8831        anchor: LogAnchor,
8832        tip: LogAnchor,
8833        tip_kind: &str,
8834    ) -> Result<(), NodeError> {
8835        if tip.index() < anchor.index() {
8836            return Err(self.latch(NodeError::Invariant(format!(
8837                "{tip_kind} tip {} precedes read barrier {}",
8838                tip.index(),
8839                anchor.index()
8840            ))));
8841        }
8842        if tip.index() == anchor.index() {
8843            if tip.hash() != anchor.hash() {
8844                return Err(self.latch(NodeError::Invariant(format!(
8845                    "{tip_kind} tip hash differs from the read barrier anchor"
8846                ))));
8847            }
8848            return Ok(());
8849        }
8850        if anchor.index() == 0 {
8851            if anchor.hash() == LogHash::ZERO {
8852                return Ok(());
8853            }
8854            return Err(self.latch(NodeError::Invariant(
8855                "genesis read barrier anchor has a non-zero hash".into(),
8856            )));
8857        }
8858
8859        let logical = self
8860            .log_store
8861            .logical_state()
8862            .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
8863        if let Some(compacted) = logical.anchor.as_ref().map(RecoveryAnchor::compacted) {
8864            if compacted.index() > anchor.index() {
8865                return Ok(());
8866            }
8867            if compacted.index() == anchor.index() {
8868                if compacted.hash() == anchor.hash() {
8869                    return Ok(());
8870                }
8871                return Err(self.latch(NodeError::Invariant(
8872                    "compacted qlog hash differs from the read barrier anchor".into(),
8873                )));
8874            }
8875        }
8876        let retained = self
8877            .log_store
8878            .read(anchor.index())
8879            .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?
8880            .ok_or_else(|| {
8881                self.latch(NodeError::Invariant(
8882                    "read barrier anchor is neither retained nor compacted".into(),
8883                ))
8884            })?;
8885        if retained.hash != anchor.hash() {
8886            return Err(self.latch(NodeError::Invariant(
8887                "retained qlog hash differs from the read barrier anchor".into(),
8888            )));
8889        }
8890        Ok(())
8891    }
8892
8893    fn commit_read_barrier_locked(&self) -> Result<LogAnchor, NodeError> {
8894        self.ensure_writes_active()?;
8895        let context_read_fence = self.consensus.supports_context_read_fence();
8896        loop {
8897            self.ensure_ready()?;
8898            let (last_index, last_hash) = self.ensure_materialized_tip()?;
8899            let slot = last_index.checked_add(1).ok_or_else(|| {
8900                self.latch(NodeError::Invariant("qlog index is exhausted".into()))
8901            })?;
8902            let inspection = if context_read_fence {
8903                match self
8904                    .consensus
8905                    .inspect_context_read_fence_at(slot, last_hash)
8906                    .map_err(|error| self.map_consensus_error(error))?
8907                {
8908                    CertifiedDecisionInspection::Committed(certified) => {
8909                        DecisionInspection::Committed(certified.entry)
8910                    }
8911                    CertifiedDecisionInspection::Empty => DecisionInspection::Empty,
8912                    CertifiedDecisionInspection::Pending => DecisionInspection::Pending,
8913                    CertifiedDecisionInspection::Unavailable => DecisionInspection::Unavailable,
8914                }
8915            } else {
8916                self.consensus
8917                    .inspect_decision_at(slot, last_hash)
8918                    .map_err(|error| self.map_consensus_error(error))?
8919            };
8920            match inspection {
8921                DecisionInspection::Committed(entry) => {
8922                    self.persist_entry(&entry, slot, last_hash)?;
8923                }
8924                DecisionInspection::Empty if context_read_fence => {
8925                    // Configuration may have been sealed while the read-only
8926                    // quorum observation was in flight. Never return an anchor
8927                    // from a configuration that is no longer write-active.
8928                    self.ensure_writes_active()?;
8929                    return Ok(LogAnchor::new(last_index, last_hash));
8930                }
8931                DecisionInspection::Pending => {
8932                    let entry = self
8933                        .consensus
8934                        .propose_at_cancellable(
8935                            slot,
8936                            last_hash,
8937                            Command::new(CommandKind::ReadBarrier, Vec::new()),
8938                            &self.operation_cancelled,
8939                        )
8940                        .map_err(|error| self.map_consensus_error(error))?;
8941                    self.persist_entry(&entry, slot, last_hash)?;
8942                    // Pending may conceal a historical phase-2 Noop whose
8943                    // asynchronous proof was never installed. It cannot fence
8944                    // this read, so inspect the following slot before returning.
8945                }
8946                DecisionInspection::Empty => {
8947                    let entry = self
8948                        .consensus
8949                        .propose_at_cancellable(
8950                            slot,
8951                            last_hash,
8952                            Command::new(CommandKind::ReadBarrier, Vec::new()),
8953                            &self.operation_cancelled,
8954                        )
8955                        .map_err(|error| self.map_consensus_error(error))?;
8956                    let is_barrier =
8957                        entry.entry_type == EntryType::Noop && entry.payload.is_empty();
8958                    self.persist_entry(&entry, slot, last_hash)?;
8959                    if is_barrier {
8960                        return Ok(LogAnchor::new(entry.index, entry.hash));
8961                    }
8962                }
8963                DecisionInspection::Unavailable => {
8964                    return Err(NodeError::Unavailable(
8965                        "decision inspection did not reach quorum".into(),
8966                    ));
8967                }
8968            }
8969        }
8970    }
8971
8972    #[cfg(feature = "sql")]
8973    fn check_request(
8974        &self,
8975        request_id: &str,
8976        payload: &[u8],
8977    ) -> Result<Option<RequestOutcome>, NodeError> {
8978        let sqlite = self.lock_sqlite()?;
8979        sqlite
8980            .check_request(request_id, payload)
8981            .map_err(|error| self.map_sqlite_error(error))
8982    }
8983
8984    #[cfg(feature = "graph")]
8985    #[cfg_attr(
8986        all(not(feature = "sql"), not(feature = "kv")),
8987        allow(irrefutable_let_patterns)
8988    )]
8989    fn check_graph_request(
8990        &self,
8991        request_id: &str,
8992        payload: &[u8],
8993    ) -> Result<Option<GraphRequestRecord>, NodeError> {
8994        let materializer = self.lock_materializer()?;
8995        let Materializer::Graph(graph) = &*materializer else {
8996            return Err(NodeError::ExecutionProfileMismatch {
8997                expected: ExecutionProfile::Graph,
8998                actual: materializer.profile(),
8999            });
9000        };
9001        graph
9002            .check_request(request_id, payload)
9003            .map_err(|error| NodeError::InvalidRequest(error.to_string()))
9004    }
9005
9006    #[cfg(feature = "kv")]
9007    #[cfg_attr(
9008        all(not(feature = "sql"), not(feature = "graph")),
9009        allow(irrefutable_let_patterns)
9010    )]
9011    fn check_kv_request(
9012        &self,
9013        request_id: &str,
9014        payload: &[u8],
9015    ) -> Result<Option<KvRequestRecord>, NodeError> {
9016        let materializer = self.lock_materializer()?;
9017        let Materializer::Kv(kv) = &*materializer else {
9018            return Err(NodeError::ExecutionProfileMismatch {
9019                expected: ExecutionProfile::Kv,
9020                actual: materializer.profile(),
9021            });
9022        };
9023        kv.check_request(request_id, payload)
9024            .map_err(|error| NodeError::InvalidRequest(error.to_string()))
9025    }
9026
9027    fn ensure_materialized_tip(&self) -> Result<(LogIndex, LogHash), NodeError> {
9028        #[cfg(test)]
9029        self.materialized_tip_checks.fetch_add(1, Ordering::Relaxed);
9030        let (last_index, last_hash) = self.durable_tip()?;
9031        let materializer = self.lock_materializer()?;
9032        let applied_tip = materializer
9033            .applied_tip()
9034            .map_err(|error| self.latch(NodeError::Storage(error)))?;
9035        let applied_index = applied_tip.index();
9036        let applied_hash = applied_tip.hash();
9037        if (applied_index, applied_hash) != (last_index, last_hash) {
9038            return Err(self.latch(NodeError::Invariant(format!(
9039                "qlog tip {last_index}/{} differs from {} materializer tip {applied_index}/{}",
9040                last_hash.to_hex(),
9041                materializer.profile(),
9042                applied_hash.to_hex()
9043            ))));
9044        }
9045        Ok((last_index, last_hash))
9046    }
9047
9048    fn durable_tip(&self) -> Result<(LogIndex, LogHash), NodeError> {
9049        static_log_tip(&self.log_store).map_err(|error| self.latch(error))
9050    }
9051
9052    fn persist_entry(
9053        &self,
9054        entry: &LogEntry,
9055        expected_index: LogIndex,
9056        expected_prev_hash: LogHash,
9057    ) -> Result<Option<SqlCommandResult>, NodeError> {
9058        let configuration_state = self.configuration_state()?;
9059        validate_runtime_entry(
9060            &self.config,
9061            &configuration_state,
9062            entry,
9063            expected_index,
9064            expected_prev_hash,
9065        )
9066        .map_err(|error| self.latch(error))?;
9067        if matches!(
9068            self.config.execution_profile,
9069            ExecutionProfile::Sqlite | ExecutionProfile::Kv
9070        ) {
9071            // SQL/KV embed the complete entry in their durable materializer state. The file qlog
9072            // remains a buffered serving mirror and is rehydrated on startup.
9073            self.log_store
9074                .append_batch_buffered(std::slice::from_ref(entry))
9075                .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9076        } else {
9077            self.log_store
9078                .append(entry)
9079                .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9080        }
9081        self.lock_materializer()?
9082            .apply_entry(entry)
9083            .map_err(|error| self.latch(NodeError::Invariant(error)))
9084    }
9085
9086    #[cfg(feature = "sql")]
9087    fn persist_sql_entry_profiled<P: SqlWritePhaseProfile>(
9088        &self,
9089        entry: &LogEntry,
9090        expected_index: LogIndex,
9091        expected_prev_hash: LogHash,
9092        profile: &mut P,
9093    ) -> Result<Option<SqlCommandResult>, NodeError> {
9094        let configuration_state = self.configuration_state()?;
9095        validate_runtime_entry(
9096            &self.config,
9097            &configuration_state,
9098            entry,
9099            expected_index,
9100            expected_prev_hash,
9101        )
9102        .map_err(|error| self.latch(error))?;
9103
9104        let qlog_mark = profile.mark();
9105        let append_result = self
9106            .log_store
9107            .append_batch_buffered(std::slice::from_ref(entry))
9108            .map_err(|error| self.latch(NodeError::Storage(error.to_string())));
9109        profile.add_local_qlog_mirror_append(qlog_mark);
9110        append_result?;
9111
9112        let materializer_mark = profile.mark();
9113        let apply_result = self
9114            .lock_materializer()?
9115            .apply_entry(entry)
9116            .map_err(|error| self.latch(NodeError::Invariant(error)));
9117        profile.add_sql_materializer_apply(materializer_mark);
9118        apply_result
9119    }
9120
9121    fn require_execution_profile(&self, expected: ExecutionProfile) -> Result<(), NodeError> {
9122        if self.config.execution_profile == expected {
9123            Ok(())
9124        } else {
9125            Err(NodeError::ExecutionProfileMismatch {
9126                expected,
9127                actual: self.config.execution_profile,
9128            })
9129        }
9130    }
9131
9132    fn ensure_ready(&self) -> Result<(), NodeError> {
9133        if self.fatal.load(Ordering::Acquire) {
9134            return Err(NodeError::Fatal(
9135                self.fatal_reason()
9136                    .unwrap_or_else(|| "fatal state is latched".into()),
9137            ));
9138        }
9139        if !self.ready.load(Ordering::Acquire) {
9140            return Err(NodeError::Unavailable("runtime is not ready".into()));
9141        }
9142        if self.checkpointing.load(Ordering::Acquire) {
9143            return Err(NodeError::Unavailable(
9144                "runtime checkpoint transition is in progress".into(),
9145            ));
9146        }
9147        Ok(())
9148    }
9149
9150    fn ensure_writes_active(&self) -> Result<(), NodeError> {
9151        let state = self.configuration_state()?;
9152        if state.is_active() {
9153            Ok(())
9154        } else {
9155            Err(NodeError::ConfigurationTransition {
9156                state: Box::new(state),
9157            })
9158        }
9159    }
9160
9161    fn lock_commit(&self) -> Result<MutexGuard<'_, ()>, NodeError> {
9162        self.commit
9163            .lock()
9164            .map_err(|_| self.latch(NodeError::Invariant("commit mutex is poisoned".into())))
9165    }
9166
9167    fn lock_materializer(&self) -> Result<MutexGuard<'_, Materializer>, NodeError> {
9168        self.materializer.lock().map_err(|_| {
9169            self.latch(NodeError::Invariant(
9170                "materializer mutex is poisoned".into(),
9171            ))
9172        })
9173    }
9174
9175    #[cfg(feature = "graph")]
9176    #[cfg_attr(
9177        all(not(feature = "sql"), not(feature = "kv")),
9178        allow(irrefutable_let_patterns)
9179    )]
9180    fn graph_materializer(&self) -> Result<Arc<LadybugStateMachine>, NodeError> {
9181        let materializer = self.lock_materializer()?;
9182        let Materializer::Graph(graph) = &*materializer else {
9183            return Err(NodeError::ExecutionProfileMismatch {
9184                expected: ExecutionProfile::Graph,
9185                actual: materializer.profile(),
9186            });
9187        };
9188        Ok(Arc::clone(graph))
9189    }
9190
9191    #[cfg(feature = "kv")]
9192    #[cfg_attr(
9193        all(not(feature = "sql"), not(feature = "graph")),
9194        allow(irrefutable_let_patterns)
9195    )]
9196    fn kv_materializer(&self) -> Result<Arc<RedbStateMachine>, NodeError> {
9197        let materializer = self.lock_materializer()?;
9198        let Materializer::Kv(kv) = &*materializer else {
9199            return Err(NodeError::ExecutionProfileMismatch {
9200                expected: ExecutionProfile::Kv,
9201                actual: materializer.profile(),
9202            });
9203        };
9204        Ok(Arc::clone(kv))
9205    }
9206
9207    #[cfg(feature = "sql")]
9208    fn lock_sqlite(&self) -> Result<SqlMaterializerGuard<'_>, NodeError> {
9209        let guard = self.lock_materializer()?;
9210        if !matches!(&*guard, Materializer::Sql(_)) {
9211            return Err(NodeError::ExecutionProfileMismatch {
9212                expected: ExecutionProfile::Sqlite,
9213                actual: guard.profile(),
9214            });
9215        }
9216        Ok(SqlMaterializerGuard(guard))
9217    }
9218
9219    #[cfg(feature = "sql")]
9220    fn map_sqlite_error(&self, error: rhiza_sql::Error) -> NodeError {
9221        match error {
9222            rhiza_sql::Error::RequestConflict(conflict) => NodeError::RequestConflict(conflict),
9223            rhiza_sql::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9224            rhiza_sql::Error::InvalidCommand(message)
9225            | rhiza_sql::Error::IdentityMismatch(message)
9226            | rhiza_sql::Error::InvalidEntry(message)
9227            | rhiza_sql::Error::InvalidSnapshot(message) => {
9228                self.latch(NodeError::Invariant(message))
9229            }
9230            other => self.latch(NodeError::Storage(other.to_string())),
9231        }
9232    }
9233
9234    #[cfg(feature = "graph")]
9235    fn map_graph_read_error(&self, error: rhiza_graph::Error) -> NodeError {
9236        match error {
9237            rhiza_graph::Error::InvalidCommand(_) => NodeError::InvalidRequest(error.to_string()),
9238            rhiza_graph::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9239            rhiza_graph::Error::Ladybug(_) | rhiza_graph::Error::Io(_) => {
9240                self.latch(NodeError::Storage(error.to_string()))
9241            }
9242            rhiza_graph::Error::Closed
9243            | rhiza_graph::Error::Codec(_)
9244            | rhiza_graph::Error::InvalidEntry(_)
9245            | rhiza_graph::Error::IdentityMismatch(_)
9246            | rhiza_graph::Error::RequestConflict { .. }
9247            | rhiza_graph::Error::InvalidSnapshot(_) => {
9248                self.latch(NodeError::Invariant(error.to_string()))
9249            }
9250        }
9251    }
9252
9253    #[cfg(feature = "kv")]
9254    fn map_kv_read_error(&self, error: rhiza_kv::Error) -> NodeError {
9255        match error {
9256            rhiza_kv::Error::InvalidCommand(_) | rhiza_kv::Error::InvalidQuery(_) => {
9257                NodeError::InvalidRequest(error.to_string())
9258            }
9259            rhiza_kv::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9260            rhiza_kv::Error::Database(_) | rhiza_kv::Error::Io(_) => {
9261                self.latch(NodeError::Storage(error.to_string()))
9262            }
9263            rhiza_kv::Error::Codec(_)
9264            | rhiza_kv::Error::InvalidEntry(_)
9265            | rhiza_kv::Error::PartialInitialization
9266            | rhiza_kv::Error::RequestConflict { .. }
9267            | rhiza_kv::Error::InvalidSnapshot(_) => {
9268                self.latch(NodeError::Invariant(error.to_string()))
9269            }
9270        }
9271    }
9272
9273    fn map_consensus_error(&self, error: rhiza_quepaxa::Error) -> NodeError {
9274        match error {
9275            rhiza_quepaxa::Error::NoQuorum
9276            | rhiza_quepaxa::Error::ProposeFailed
9277            | rhiza_quepaxa::Error::CommandUnavailable
9278            | rhiza_quepaxa::Error::Cancelled
9279            | rhiza_quepaxa::Error::Io(_) => NodeError::Unavailable(error.to_string()),
9280            rhiza_quepaxa::Error::ConflictingCertificates
9281            | rhiza_quepaxa::Error::ChainConflict { .. } => {
9282                self.latch(NodeError::Reconciliation(error.to_string()))
9283            }
9284            other => self.latch(NodeError::Invariant(other.to_string())),
9285        }
9286    }
9287
9288    fn latch(&self, error: NodeError) -> NodeError {
9289        self.ready.store(false, Ordering::Release);
9290        if !self.fatal.swap(true, Ordering::AcqRel) {
9291            eprintln!(
9292                "node runtime entered fatal state: {}",
9293                escaped_error_detail(&error)
9294            );
9295            if let Ok(mut reason) = self.fatal_reason.lock() {
9296                *reason = Some(error.to_string());
9297            }
9298        }
9299        error
9300    }
9301}
9302
9303pub fn rehydrate_recorder_after_checkpoint(
9304    runtime: &NodeRuntime,
9305    recorder: &RecorderFileStore,
9306    checkpoint_index: LogIndex,
9307) -> Result<(), NodeError> {
9308    if let Some(anchor) = runtime
9309        .log_store()
9310        .logical_state()
9311        .map_err(|error| NodeError::Storage(error.to_string()))?
9312        .anchor
9313    {
9314        if checkpoint_index < anchor.compacted().index() {
9315            return Err(NodeError::SnapshotRequired(Box::new(anchor)));
9316        }
9317    }
9318    let applied_index = runtime.applied_index()?;
9319    if checkpoint_index > applied_index {
9320        return Err(NodeError::Reconciliation(format!(
9321            "checkpoint tip {checkpoint_index} is ahead of local applied index {applied_index}"
9322        )));
9323    }
9324
9325    for index in checkpoint_index.saturating_add(1)..=applied_index {
9326        let entry = runtime
9327            .log_store()
9328            .read(index)
9329            .map_err(|error| NodeError::Storage(error.to_string()))?
9330            .ok_or_else(|| {
9331                NodeError::Reconciliation(format!(
9332                    "qlog entry {index} is missing during recorder rehydration"
9333                ))
9334            })?;
9335        let certified = match runtime
9336            .consensus()
9337            .inspect_certified_decision_at(index, entry.prev_hash)
9338            .map_err(startup_consensus_error)?
9339        {
9340            CertifiedDecisionInspection::Committed(certified) => certified,
9341            CertifiedDecisionInspection::Empty => {
9342                return Err(NodeError::Reconciliation(format!(
9343                    "qlog entry {index} has no recorder decision certificate"
9344                )))
9345            }
9346            CertifiedDecisionInspection::Pending => {
9347                return Err(NodeError::Reconciliation(format!(
9348                    "qlog entry {index} has only a pending recorder decision"
9349                )))
9350            }
9351            CertifiedDecisionInspection::Unavailable => {
9352                return Err(NodeError::Unavailable(format!(
9353                    "recorder decision certificate is unavailable at qlog index {index}"
9354                )))
9355            }
9356        };
9357        if certified.entry != entry {
9358            return Err(NodeError::Reconciliation(format!(
9359                "recorder decision certificate differs from qlog entry {index}"
9360            )));
9361        }
9362        let command = StoredCommand::new(entry.entry_type, entry.payload.clone());
9363        recorder
9364            .store_command(command.hash(), command)
9365            .map_err(|error| {
9366                NodeError::Reconciliation(format!(
9367                    "cannot restore recorder command at qlog index {index}: {error}"
9368                ))
9369            })?;
9370        let proof = certified.proof.clone();
9371        recorder
9372            .install_decision_proof_record(proof, runtime.consensus().membership())
9373            .map_err(|error| {
9374                NodeError::Reconciliation(format!(
9375                    "cannot install recorder decision at qlog index {index}: {error}"
9376                ))
9377            })?;
9378    }
9379    Ok(())
9380}
9381
9382#[cfg(test)]
9383mod tests {
9384    use axum::http::HeaderValue;
9385
9386    use rhiza_core::{
9387        Command, CommandKind, ConfigurationState, EntryType, ErrorCategory, ErrorClassification,
9388        ExecutionProfile, LogAnchor, LogHash, RecoveryAnchor, SnapshotIdentity, StoredCommand,
9389    };
9390    #[cfg(feature = "graph")]
9391    use rhiza_graph::{GraphCommandV1, GraphValueV1};
9392    #[cfg(feature = "kv")]
9393    use rhiza_kv::KvCommandV1;
9394    use rhiza_log::LogStore as _;
9395    use rhiza_quepaxa::{
9396        AcceptedValue, Membership, Proposal, ProposalPriority, RecordRequest, ThreeNodeConsensus,
9397    };
9398    use std::sync::{
9399        atomic::{AtomicBool, AtomicUsize, Ordering},
9400        mpsc, Arc, Barrier,
9401    };
9402
9403    #[cfg(any(feature = "sql", feature = "graph", feature = "kv"))]
9404    use super::node_error_response;
9405    #[cfg(feature = "graph")]
9406    use super::with_graph_client_permit;
9407    use super::ReadBarrierRounds;
9408    use super::{
9409        client_authenticated, next_sync_flush_retry, run_read_operation, sql_query_http_response,
9410        valid_recorder_record, Duration, HeaderMap, NodeError, ReadConsistency, SqlCommand,
9411        SqlQueryResponse, SqlStatement, SqlValue, SqlWriteProfiler, MAX_COMMAND_BYTES,
9412        MAX_SQL_RESPONSE_BYTES, PROTOCOL_VERSION, QWAL_V3_MAGIC, SYNC_FLUSH_RETRY_INITIAL,
9413        VERSION_HEADER,
9414    };
9415    use super::{ConfigError, NodeConfig, NodeRuntime, NodeService};
9416
9417    #[test]
9418    fn embedded_config_accepts_matching_canonical_profile_ids() {
9419        for (cluster_id, profile) in [
9420            ("rhiza:graph:embedded", ExecutionProfile::Graph),
9421            ("rhiza:kv:embedded", ExecutionProfile::Kv),
9422        ] {
9423            let config = NodeConfig::new_embedded(
9424                cluster_id,
9425                "n1",
9426                std::env::temp_dir().join(profile.as_str()),
9427                1,
9428                1,
9429                ["n1", "n2", "n3"],
9430            )
9431            .unwrap()
9432            .with_execution_profile(profile)
9433            .unwrap();
9434
9435            assert_eq!(config.cluster_id(), cluster_id);
9436            assert_eq!(config.logical_cluster_id(), "embedded");
9437        }
9438    }
9439
9440    #[test]
9441    fn embedded_config_rejects_conflicting_canonical_profile_and_preserves_logical_ids() {
9442        let conflicting = NodeConfig::new_embedded(
9443            "rhiza:graph:embedded",
9444            "n1",
9445            std::env::temp_dir().join("conflicting-profile"),
9446            1,
9447            1,
9448            ["n1", "n2", "n3"],
9449        )
9450        .unwrap()
9451        .with_execution_profile(ExecutionProfile::Sqlite)
9452        .unwrap_err();
9453        assert!(matches!(
9454            conflicting,
9455            ConfigError::ClusterIdProfileMismatch {
9456                expected: ExecutionProfile::Sqlite,
9457                actual: ExecutionProfile::Graph,
9458            }
9459        ));
9460
9461        let logical = NodeConfig::new_embedded(
9462            "embedded",
9463            "n1",
9464            std::env::temp_dir().join("logical-profile"),
9465            1,
9466            1,
9467            ["n1", "n2", "n3"],
9468        )
9469        .unwrap();
9470        assert_eq!(logical.cluster_id(), "rhiza:sql:embedded");
9471        assert_eq!(logical.logical_cluster_id(), "embedded");
9472    }
9473
9474    #[test]
9475    fn node_error_classification_reports_observable_retry_semantics() {
9476        let cases = [
9477            (
9478                NodeError::InvalidRequest("missing key".into()),
9479                "invalid_request",
9480                ErrorCategory::InvalidRequest,
9481                false,
9482            ),
9483            (
9484                NodeError::PreconditionFailed("stale version".into()),
9485                "precondition_failed",
9486                ErrorCategory::Conflict,
9487                false,
9488            ),
9489            (
9490                NodeError::Unavailable("no quorum".into()),
9491                "unavailable",
9492                ErrorCategory::Unavailable,
9493                true,
9494            ),
9495            (
9496                NodeError::ResourceExhausted("result too large".into()),
9497                "resource_exhausted",
9498                ErrorCategory::ResourceExhausted,
9499                true,
9500            ),
9501            (
9502                NodeError::Invariant("invalid log".into()),
9503                "invariant_violation",
9504                ErrorCategory::Internal,
9505                false,
9506            ),
9507        ];
9508
9509        for (error, code, category, retryable) in cases {
9510            let classification = error.classification();
9511
9512            assert_eq!(classification.code(), code);
9513            assert_eq!(classification.category(), category);
9514            assert_eq!(classification.retryable(), retryable);
9515        }
9516    }
9517
9518    #[cfg(feature = "sql")]
9519    #[test]
9520    fn sql_batch_error_classification_preserves_statement_index_category() {
9521        let error = NodeError::InvalidSqlStatement {
9522            statement_index: 3,
9523            message: "syntax error".into(),
9524        };
9525
9526        let classification = error.classification();
9527
9528        assert_eq!(classification.code(), "invalid_request");
9529        assert_eq!(classification.category(), ErrorCategory::InvalidRequest);
9530        assert!(!classification.retryable());
9531    }
9532
9533    #[cfg(feature = "sql")]
9534    #[tokio::test]
9535    async fn node_error_http_response_preserves_v1_contract() {
9536        let snapshot = RecoveryAnchor::new(
9537            "cluster",
9538            1,
9539            ConfigurationState::active(1, LogHash::digest(&[b"node-error-test-config"])),
9540            1,
9541            LogAnchor::new(1, LogHash::ZERO),
9542            SnapshotIdentity::new(
9543                "snapshot",
9544                LogHash::digest(&[b"node-error-test-snapshot"]),
9545                0,
9546                rhiza_sql::sql_executor_fingerprint().unwrap(),
9547            ),
9548        );
9549        let cases = vec![
9550            (
9551                NodeError::InvalidSqlStatement {
9552                    statement_index: 3,
9553                    message: "syntax error".into(),
9554                },
9555                axum::http::StatusCode::BAD_REQUEST,
9556                "invalid_request",
9557                false,
9558                Some(3),
9559            ),
9560            (
9561                NodeError::PreconditionFailed("stale version".into()),
9562                axum::http::StatusCode::CONFLICT,
9563                "precondition_failed",
9564                false,
9565                None,
9566            ),
9567            (
9568                NodeError::SnapshotRequired(Box::new(snapshot)),
9569                axum::http::StatusCode::SERVICE_UNAVAILABLE,
9570                "snapshot_required",
9571                false,
9572                None,
9573            ),
9574            (
9575                NodeError::Storage("disk failed".into()),
9576                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
9577                "storage_error",
9578                false,
9579                None,
9580            ),
9581        ];
9582
9583        for (node_error, status, code, retryable, statement_index) in cases {
9584            let response = node_error_response(node_error);
9585            assert_eq!(response.status(), status);
9586            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9587                .await
9588                .unwrap();
9589            let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
9590            let error: super::ClientErrorResponse = serde_json::from_value(value.clone()).unwrap();
9591            assert_eq!(error.code, code);
9592            assert_eq!(error.retryable, retryable);
9593            assert_eq!(error.statement_index, statement_index);
9594            assert!(value.get("category").is_none());
9595            assert!(matches!(
9596                error.message.as_str(),
9597                "request could not be processed"
9598                    | "request conflicts with current state"
9599                    | "service is temporarily unavailable"
9600                    | "internal server error"
9601            ));
9602        }
9603    }
9604
9605    #[cfg(feature = "sql")]
9606    #[tokio::test]
9607    async fn node_error_http_response_hides_display_details() {
9608        let detail = "/srv/rhiza/private/consensus/log: checksum mismatch";
9609        let cases = [
9610            (
9611                NodeError::Storage(detail.into()),
9612                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
9613                "storage_error",
9614                None,
9615                "internal server error",
9616            ),
9617            (
9618                NodeError::Reconciliation(detail.into()),
9619                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
9620                "reconciliation_error",
9621                None,
9622                "internal server error",
9623            ),
9624            (
9625                NodeError::InvalidSqlStatement {
9626                    statement_index: 7,
9627                    message: detail.into(),
9628                },
9629                axum::http::StatusCode::BAD_REQUEST,
9630                "invalid_request",
9631                Some(7),
9632                "request could not be processed",
9633            ),
9634        ];
9635
9636        for (node_error, status, code, statement_index, message) in cases {
9637            let response = node_error_response(node_error);
9638            assert_eq!(response.status(), status);
9639            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9640                .await
9641                .unwrap();
9642            let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
9643            assert_eq!(error.code, code);
9644            assert_eq!(error.statement_index, statement_index);
9645            assert_eq!(error.message, message);
9646            assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
9647        }
9648    }
9649
9650    #[tokio::test]
9651    async fn client_json_error_response_uses_a_stable_message() {
9652        for (status, code) in [
9653            (axum::http::StatusCode::BAD_REQUEST, "invalid_json"),
9654            (
9655                axum::http::StatusCode::PAYLOAD_TOO_LARGE,
9656                "payload_too_large",
9657            ),
9658        ] {
9659            let response = super::client_json_error_response(status);
9660
9661            assert_eq!(response.status(), status);
9662            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9663                .await
9664                .unwrap();
9665            let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
9666            assert_eq!(error.code, code);
9667            assert!(!error.retryable);
9668            assert_eq!(error.message, "request body is invalid");
9669            assert_eq!(error.statement_index, None);
9670        }
9671    }
9672
9673    #[tokio::test]
9674    async fn client_task_error_hides_join_error_details() {
9675        let detail = "private task detail\nforged log entry";
9676        let error = tokio::spawn(async move { panic!("{detail}") })
9677            .await
9678            .unwrap_err();
9679        let response = super::client_task_error(error);
9680
9681        assert_eq!(
9682            response.status(),
9683            axum::http::StatusCode::INTERNAL_SERVER_ERROR
9684        );
9685        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9686            .await
9687            .unwrap();
9688        let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
9689        assert_eq!(error.code, "task_failed");
9690        assert!(!error.retryable);
9691        assert_eq!(error.message, "request task failed");
9692        assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
9693    }
9694
9695    #[cfg(feature = "sql")]
9696    #[tokio::test]
9697    async fn fatal_readiness_response_hides_fatal_reason() {
9698        let (_dir, runtime) = sql_test_runtime();
9699        let detail = "/srv/rhiza/private\nforged log entry";
9700        runtime.latch(NodeError::Storage(detail.into()));
9701        let response = super::runtime_readiness_response(&runtime).unwrap();
9702
9703        assert_eq!(
9704            response.status(),
9705            axum::http::StatusCode::INTERNAL_SERVER_ERROR
9706        );
9707        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9708            .await
9709            .unwrap();
9710        let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
9711        assert_eq!(error.code, "fatal");
9712        assert!(!error.retryable);
9713        assert_eq!(error.message, "node is fatally unavailable");
9714        assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
9715    }
9716
9717    #[test]
9718    fn escaped_error_detail_escapes_control_characters() {
9719        let detail = "checksum mismatch\nforged entry\r\u{1b}[2J";
9720
9721        assert_eq!(
9722            super::escaped_error_detail(&detail),
9723            r"checksum mismatch\nforged entry\r\u{1b}[2J"
9724        );
9725    }
9726
9727    #[test]
9728    fn escaped_error_detail_bounds_escape_expansion() {
9729        let detail = "\n".repeat(super::MAX_ESCAPED_ERROR_DETAIL_BYTES / 2 + 1);
9730        let escaped = super::escaped_error_detail(&detail);
9731
9732        assert!(
9733            escaped.len() <= super::MAX_ESCAPED_ERROR_DETAIL_BYTES,
9734            "escaped detail must stay within the log budget"
9735        );
9736        assert!(
9737            escaped.ends_with(super::ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER),
9738            "truncated details must be explicit"
9739        );
9740        assert!(!escaped.contains('\n'));
9741    }
9742
9743    #[tokio::test]
9744    async fn client_error_responses_preserve_payload_and_authentication_wire_codes() {
9745        for (status, code, retryable, category) in [
9746            (
9747                axum::http::StatusCode::PAYLOAD_TOO_LARGE,
9748                "payload_too_large",
9749                false,
9750                ErrorCategory::ResourceExhausted,
9751            ),
9752            (
9753                axum::http::StatusCode::UNAUTHORIZED,
9754                "unauthorized",
9755                false,
9756                ErrorCategory::Authentication,
9757            ),
9758        ] {
9759            let response =
9760                super::client_error_response(status, code, retryable, "request failed", None);
9761            assert_eq!(response.status(), status);
9762            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9763                .await
9764                .unwrap();
9765            let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
9766            let error: super::ClientErrorResponse = serde_json::from_value(value.clone()).unwrap();
9767            assert_eq!(error.code, code);
9768            assert_eq!(error.retryable, retryable);
9769            assert!(value.get("category").is_none());
9770            assert_eq!(
9771                ErrorClassification::from_server_code(code, retryable).category(),
9772                category
9773            );
9774        }
9775    }
9776
9777    #[test]
9778    fn concurrent_read_barriers_registered_before_cutoff_share_one_generation() {
9779        let rounds = ReadBarrierRounds::new(Duration::ZERO);
9780        let cancelled = AtomicBool::new(false);
9781        let participants = (0..4).map(|_| rounds.join().unwrap()).collect::<Vec<_>>();
9782        let generation = participants[0].generation();
9783        assert!(participants[0].is_leader());
9784        assert!(participants[1..]
9785            .iter()
9786            .all(|participant| !participant.is_leader() && participant.generation() == generation));
9787
9788        let calls = AtomicUsize::new(0);
9789        let mut publication = participants[0].publication().unwrap();
9790        publication.wait_turn(&cancelled).unwrap();
9791        publication.start(&cancelled).unwrap();
9792        let anchor = LogAnchor::new(7, LogHash::digest(&[b"shared-barrier"]));
9793        calls.fetch_add(1, Ordering::Relaxed);
9794        publication.publish(Ok(anchor));
9795
9796        assert_eq!(calls.load(Ordering::Relaxed), 1);
9797        for participant in &participants[1..] {
9798            assert_eq!(participant.wait(&cancelled).unwrap(), anchor);
9799        }
9800    }
9801
9802    #[test]
9803    fn read_barrier_arriving_after_running_cutoff_uses_next_generation() {
9804        let rounds = Arc::new(ReadBarrierRounds::new(Duration::ZERO));
9805        let cancelled = Arc::new(AtomicBool::new(false));
9806        let first = rounds.join().unwrap();
9807        let first_generation = first.generation();
9808        let (running_tx, running_rx) = mpsc::channel();
9809        let (release_tx, release_rx) = mpsc::channel();
9810        let first_cancelled = Arc::clone(&cancelled);
9811        let first_worker = std::thread::spawn(move || {
9812            let mut publication = first.publication().unwrap();
9813            publication.wait_turn(&first_cancelled).unwrap();
9814            publication.start(&first_cancelled).unwrap();
9815            running_tx.send(()).unwrap();
9816            release_rx.recv().unwrap();
9817            let anchor = LogAnchor::new(1, LogHash::digest(&[b"first"]));
9818            publication.publish(Ok(anchor));
9819            anchor
9820        });
9821        running_rx.recv().unwrap();
9822
9823        let late = rounds.join().unwrap();
9824        assert!(late.is_leader());
9825        assert_eq!(late.generation(), first_generation + 1);
9826        release_tx.send(()).unwrap();
9827        assert_eq!(first_worker.join().unwrap().index(), 1);
9828
9829        let mut publication = late.publication().unwrap();
9830        publication.wait_turn(&cancelled).unwrap();
9831        publication.start(&cancelled).unwrap();
9832        let second = LogAnchor::new(2, LogHash::digest(&[b"second"]));
9833        publication.publish(Ok(second));
9834        assert_eq!(late.wait(&cancelled).unwrap(), second);
9835    }
9836
9837    #[test]
9838    fn completed_read_barrier_is_not_reused_and_predecessor_failure_retries_independently() {
9839        let rounds = ReadBarrierRounds::new(Duration::ZERO);
9840        let cancelled = AtomicBool::new(false);
9841        let failed = rounds.join().unwrap();
9842        let failed_generation = failed.generation();
9843        let mut publication = failed.publication().unwrap();
9844        publication.wait_turn(&cancelled).unwrap();
9845        publication.start(&cancelled).unwrap();
9846        publication.publish(Err(NodeError::Unavailable("no quorum".into())));
9847        assert!(matches!(
9848            failed.wait(&cancelled),
9849            Err(NodeError::Unavailable(_))
9850        ));
9851
9852        let retry = rounds.join().unwrap();
9853        assert!(retry.is_leader());
9854        assert_eq!(retry.generation(), failed_generation + 1);
9855        let mut publication = retry.publication().unwrap();
9856        publication.wait_turn(&cancelled).unwrap();
9857        publication.start(&cancelled).unwrap();
9858        let anchor = LogAnchor::new(1, LogHash::digest(&[b"retry"]));
9859        publication.publish(Ok(anchor));
9860        assert_eq!(retry.wait(&cancelled).unwrap(), anchor);
9861
9862        let later = rounds.join().unwrap();
9863        assert!(later.is_leader());
9864        assert_eq!(later.generation(), retry.generation() + 1);
9865    }
9866
9867    #[test]
9868    fn read_barrier_leader_drop_and_global_cancel_wake_waiters() {
9869        let rounds = Arc::new(ReadBarrierRounds::new(Duration::ZERO));
9870        let cancelled = Arc::new(AtomicBool::new(false));
9871        let abandoned = rounds.join().unwrap();
9872        let follower = rounds.join().unwrap();
9873        drop(abandoned.publication().unwrap());
9874        assert!(matches!(
9875            follower.wait(&cancelled),
9876            Err(NodeError::Unavailable(_))
9877        ));
9878
9879        let leader = rounds.join().unwrap();
9880        let waiting = rounds.join().unwrap();
9881        let waiting_cancelled = Arc::clone(&cancelled);
9882        let waiter = std::thread::spawn(move || waiting.wait(&waiting_cancelled));
9883        cancelled.store(true, Ordering::Release);
9884        rounds.cancel_waiters();
9885        assert!(matches!(
9886            waiter.join().unwrap(),
9887            Err(NodeError::Unavailable(_))
9888        ));
9889        drop(leader.publication().unwrap());
9890    }
9891
9892    #[test]
9893    fn sql_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
9894        let (_dir, mut runtime) = sql_test_runtime();
9895        runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
9896        let runtime = Arc::new(runtime);
9897        let start = Arc::new(Barrier::new(4));
9898        let workers = (0..4)
9899            .map(|_| {
9900                let runtime = Arc::clone(&runtime);
9901                let start = Arc::clone(&start);
9902                std::thread::spawn(move || {
9903                    start.wait();
9904                    runtime.read("missing", ReadConsistency::ReadBarrier)
9905                })
9906            })
9907            .collect::<Vec<_>>();
9908
9909        let responses = workers
9910            .into_iter()
9911            .map(|worker| worker.join().unwrap().unwrap())
9912            .collect::<Vec<_>>();
9913        assert!(responses
9914            .iter()
9915            .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
9916        assert_eq!(runtime.log_store().last_index().unwrap(), None);
9917    }
9918
9919    #[test]
9920    fn read_barrier_anchor_remains_valid_when_materialized_tip_advances() {
9921        let (_dir, runtime) = sql_test_runtime();
9922        let anchor = runtime.establish_read_barrier().unwrap();
9923        let write = runtime.write("request-1", "alpha", "one").unwrap();
9924        assert!(write.applied_index > anchor.index());
9925
9926        let _commit = runtime.lock_commit().unwrap();
9927        runtime.ensure_ready().unwrap();
9928        runtime.ensure_writes_active().unwrap();
9929        runtime
9930            .validate_read_barrier_descendant_locked(anchor)
9931            .unwrap();
9932        let read = runtime.read_local("alpha", Some(anchor.index())).unwrap();
9933
9934        assert_eq!(read.value.as_deref(), Some("one"));
9935        assert_eq!(read.applied_index, write.applied_index);
9936        assert_eq!(read.hash, write.hash);
9937    }
9938
9939    #[cfg(feature = "graph")]
9940    #[test]
9941    fn graph_read_barrier_checks_materialized_tip_once_before_snapshot() {
9942        let (_dir, runtime) = graph_test_runtime();
9943
9944        let response = runtime
9945            .get_graph_document("missing", ReadConsistency::ReadBarrier)
9946            .unwrap();
9947
9948        assert_eq!(response.applied_index, 0);
9949        assert_eq!(response.hash, LogHash::ZERO);
9950        assert_eq!(runtime.materialized_tip_checks.load(Ordering::Relaxed), 1);
9951    }
9952
9953    #[cfg(feature = "graph")]
9954    #[test]
9955    fn graph_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
9956        let (_dir, mut runtime) = graph_test_runtime();
9957        runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
9958        let runtime = Arc::new(runtime);
9959        let start = Arc::new(Barrier::new(4));
9960        let workers = (0..4)
9961            .map(|_| {
9962                let runtime = Arc::clone(&runtime);
9963                let start = Arc::clone(&start);
9964                std::thread::spawn(move || {
9965                    start.wait();
9966                    runtime.get_graph_document("missing", ReadConsistency::ReadBarrier)
9967                })
9968            })
9969            .collect::<Vec<_>>();
9970
9971        let responses = workers
9972            .into_iter()
9973            .map(|worker| worker.join().unwrap().unwrap())
9974            .collect::<Vec<_>>();
9975        assert!(responses
9976            .iter()
9977            .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
9978        assert_eq!(runtime.log_store().last_index().unwrap(), None);
9979    }
9980
9981    #[cfg(feature = "graph")]
9982    #[test]
9983    fn graph_read_barrier_releases_commit_lock_before_backend_snapshot() {
9984        let (_dir, mut runtime) = graph_test_runtime();
9985        let initial = runtime
9986            .mutate_graph(
9987                GraphCommandV1::put_document(
9988                    "request-1",
9989                    "document-1",
9990                    GraphValueV1::String("one".into()),
9991                )
9992                .unwrap(),
9993            )
9994            .unwrap();
9995        let entered = Arc::new(Barrier::new(2));
9996        let release = Arc::new(Barrier::new(2));
9997        runtime.read_barrier_before_snapshot_hook = Some(Arc::new({
9998            let entered = Arc::clone(&entered);
9999            let release = Arc::clone(&release);
10000            move || {
10001                entered.wait();
10002                release.wait();
10003            }
10004        }));
10005        let runtime = Arc::new(runtime);
10006        let reader = {
10007            let runtime = Arc::clone(&runtime);
10008            std::thread::spawn(move || {
10009                runtime.get_graph_document("document-1", ReadConsistency::ReadBarrier)
10010            })
10011        };
10012        entered.wait();
10013        let (advanced_tx, advanced_rx) = mpsc::channel();
10014        let writer = {
10015            let runtime = Arc::clone(&runtime);
10016            std::thread::spawn(move || {
10017                let outcome = runtime
10018                    .mutate_graph(
10019                        GraphCommandV1::put_document(
10020                            "request-2",
10021                            "document-1",
10022                            GraphValueV1::String("two".into()),
10023                        )
10024                        .unwrap(),
10025                    )
10026                    .unwrap();
10027                advanced_tx
10028                    .send((outcome.applied_index(), outcome.hash()))
10029                    .unwrap();
10030                outcome
10031            })
10032        };
10033
10034        let advanced_before_snapshot = advanced_rx.recv_timeout(Duration::from_secs(2));
10035        release.wait();
10036        let written = writer.join().unwrap();
10037        let read = reader.join().unwrap().unwrap();
10038
10039        assert!(
10040            advanced_before_snapshot.is_ok(),
10041            "graph write must advance while the read is paused before its backend snapshot"
10042        );
10043        assert!(read.applied_index >= initial.applied_index());
10044        if read.applied_index == initial.applied_index() {
10045            assert_eq!(read.hash, initial.hash());
10046        }
10047        assert_eq!(read.applied_index, written.applied_index());
10048        assert_eq!(read.hash, written.hash());
10049    }
10050
10051    #[cfg(feature = "graph")]
10052    #[test]
10053    fn read_barrier_rejects_same_index_snapshot_with_different_hash() {
10054        let (_dir, runtime) = graph_test_runtime();
10055        let anchor = LogAnchor::new(7, LogHash::digest(&[b"barrier-anchor"]));
10056        let observed = LogAnchor::new(7, LogHash::digest(&[b"divergent-snapshot"]));
10057
10058        assert!(matches!(
10059            runtime.validate_read_barrier_snapshot(anchor, observed),
10060            Err(NodeError::Invariant(message))
10061                if message.contains("snapshot tip hash differs")
10062        ));
10063        assert!(runtime.is_fatal());
10064    }
10065
10066    #[cfg(feature = "kv")]
10067    #[test]
10068    fn kv_read_barrier_checks_materialized_tip_once_before_snapshot() {
10069        let (_dir, runtime) = kv_test_runtime();
10070
10071        let response = runtime
10072            .get_kv(b"missing", ReadConsistency::ReadBarrier)
10073            .unwrap();
10074
10075        assert_eq!(response.applied_index, 0);
10076        assert_eq!(response.hash, LogHash::ZERO);
10077        assert_eq!(runtime.materialized_tip_checks.load(Ordering::Relaxed), 1);
10078    }
10079
10080    #[cfg(feature = "kv")]
10081    #[test]
10082    fn kv_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
10083        let (_dir, mut runtime) = kv_test_runtime();
10084        runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
10085        let runtime = Arc::new(runtime);
10086        let start = Arc::new(Barrier::new(4));
10087        let workers = (0..4)
10088            .map(|_| {
10089                let runtime = Arc::clone(&runtime);
10090                let start = Arc::clone(&start);
10091                std::thread::spawn(move || {
10092                    start.wait();
10093                    runtime.get_kv(b"missing", ReadConsistency::ReadBarrier)
10094                })
10095            })
10096            .collect::<Vec<_>>();
10097
10098        let responses = workers
10099            .into_iter()
10100            .map(|worker| worker.join().unwrap().unwrap())
10101            .collect::<Vec<_>>();
10102        assert!(responses
10103            .iter()
10104            .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
10105        assert_eq!(runtime.log_store().last_index().unwrap(), None);
10106    }
10107
10108    #[cfg(feature = "kv")]
10109    #[test]
10110    fn kv_read_barrier_releases_commit_lock_before_backend_snapshot() {
10111        let (_dir, mut runtime) = kv_test_runtime();
10112        let initial = runtime
10113            .mutate_kv(KvCommandV1::put("request-1", b"key".to_vec(), b"one".to_vec()).unwrap())
10114            .unwrap();
10115        let entered = Arc::new(Barrier::new(2));
10116        let release = Arc::new(Barrier::new(2));
10117        runtime.read_barrier_before_snapshot_hook = Some(Arc::new({
10118            let entered = Arc::clone(&entered);
10119            let release = Arc::clone(&release);
10120            move || {
10121                entered.wait();
10122                release.wait();
10123            }
10124        }));
10125        let runtime = Arc::new(runtime);
10126        let reader = {
10127            let runtime = Arc::clone(&runtime);
10128            std::thread::spawn(move || runtime.get_kv(b"key", ReadConsistency::ReadBarrier))
10129        };
10130        entered.wait();
10131        let (advanced_tx, advanced_rx) = mpsc::channel();
10132        let writer = {
10133            let runtime = Arc::clone(&runtime);
10134            std::thread::spawn(move || {
10135                let outcome = runtime
10136                    .mutate_kv(
10137                        KvCommandV1::put("request-2", b"key".to_vec(), b"two".to_vec()).unwrap(),
10138                    )
10139                    .unwrap();
10140                advanced_tx
10141                    .send((outcome.applied_index(), outcome.hash()))
10142                    .unwrap();
10143                outcome
10144            })
10145        };
10146
10147        let advanced_before_snapshot = advanced_rx.recv_timeout(Duration::from_secs(2));
10148        release.wait();
10149        let written = writer.join().unwrap();
10150        let read = reader.join().unwrap().unwrap();
10151
10152        assert!(
10153            advanced_before_snapshot.is_ok(),
10154            "KV write must advance while the read is paused before its backend snapshot"
10155        );
10156        assert!(read.applied_index >= initial.applied_index());
10157        if read.applied_index == initial.applied_index() {
10158            assert_eq!(read.hash, initial.hash());
10159        }
10160        assert_eq!(read.applied_index, written.applied_index());
10161        assert_eq!(read.hash, written.hash());
10162    }
10163
10164    #[test]
10165    fn client_authentication_rejects_empty_expected_token() {
10166        let mut headers = HeaderMap::new();
10167        headers.insert(VERSION_HEADER, HeaderValue::from_static(PROTOCOL_VERSION));
10168        headers.insert("authorization", HeaderValue::from_static("Bearer "));
10169
10170        assert!(!client_authenticated(&headers, ""));
10171    }
10172
10173    #[test]
10174    fn recorder_record_rejects_oversized_inline_command() {
10175        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
10176        let command = StoredCommand::new(
10177            EntryType::Command,
10178            vec![0_u8; MAX_COMMAND_BYTES.saturating_add(1)],
10179        );
10180        let request = RecordRequest {
10181            cluster_id: "rhiza:sql:node-unit-test".into(),
10182            epoch: 1,
10183            config_id: 1,
10184            config_digest: membership.digest(),
10185            slot: 1,
10186            step: 1,
10187            proposal: Proposal::new(
10188                ProposalPriority::MAX,
10189                "n1",
10190                1,
10191                AcceptedValue::from_command(
10192                    "rhiza:sql:node-unit-test",
10193                    1,
10194                    1,
10195                    1,
10196                    LogHash::ZERO,
10197                    &command,
10198                ),
10199            ),
10200            command: Some(command),
10201        };
10202
10203        assert!(!valid_recorder_record(&request));
10204    }
10205
10206    #[test]
10207    fn sync_flush_retry_doubles_to_a_jitter_free_cap() {
10208        let mut delay = SYNC_FLUSH_RETRY_INITIAL;
10209        let mut delays = Vec::new();
10210        for _ in 0..7 {
10211            delays.push(delay);
10212            delay = next_sync_flush_retry(delay);
10213        }
10214
10215        assert_eq!(
10216            delays,
10217            [50, 100, 200, 400, 800, 1_000, 1_000].map(Duration::from_millis)
10218        );
10219    }
10220
10221    #[test]
10222    fn blocking_operation_offloads_on_current_thread_runtime() {
10223        let runtime = tokio::runtime::Builder::new_current_thread()
10224            .build()
10225            .unwrap();
10226        runtime.block_on(async {
10227            let caller = std::thread::current().id();
10228            let worker = run_read_operation(ReadConsistency::Local, || std::thread::current().id())
10229                .await
10230                .unwrap();
10231
10232            assert_ne!(worker, caller);
10233        });
10234    }
10235
10236    #[test]
10237    fn blocking_operation_runs_inline_on_multi_thread_runtime() {
10238        let runtime = tokio::runtime::Builder::new_multi_thread()
10239            .worker_threads(2)
10240            .build()
10241            .unwrap();
10242        runtime.block_on(async {
10243            let caller = std::thread::current().id();
10244            let worker = run_read_operation(ReadConsistency::AppliedIndex(1), || {
10245                std::thread::current().id()
10246            })
10247            .await
10248            .unwrap();
10249
10250            assert_eq!(worker, caller);
10251        });
10252    }
10253
10254    #[test]
10255    fn read_barrier_offloads_on_multi_thread_runtime() {
10256        let runtime = tokio::runtime::Builder::new_multi_thread()
10257            .worker_threads(2)
10258            .build()
10259            .unwrap();
10260        runtime.block_on(async {
10261            let caller = std::thread::current().id();
10262            let worker =
10263                run_read_operation(ReadConsistency::ReadBarrier, || std::thread::current().id())
10264                    .await
10265                    .unwrap();
10266
10267            assert_ne!(worker, caller);
10268        });
10269    }
10270
10271    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10272    async fn node_service_adaptive_sql_read_returns_point_and_query_results() {
10273        let (_dir, runtime) = sql_test_runtime();
10274        let service = NodeService::new(Arc::new(runtime), None);
10275
10276        let point = service
10277            .read("missing", ReadConsistency::Local)
10278            .await
10279            .unwrap();
10280        let query = service
10281            .query(
10282                SqlStatement {
10283                    sql: "SELECT ?1 AS value".into(),
10284                    parameters: vec![SqlValue::Integer(7)],
10285                },
10286                ReadConsistency::AppliedIndex(0),
10287                1,
10288            )
10289            .await
10290            .unwrap();
10291
10292        assert_eq!(point.value, None);
10293        assert_eq!(query.columns, vec!["value"]);
10294        assert_eq!(query.rows, vec![vec![SqlValue::Integer(7)]]);
10295    }
10296
10297    #[test]
10298    fn node_service_adaptive_sql_read_stays_inline_and_recovers_direct_panic() {
10299        let (_dir, runtime) = sql_test_runtime();
10300        let service = NodeService::new(Arc::new(runtime), None);
10301        let runtime = tokio::runtime::Builder::new_multi_thread()
10302            .worker_threads(2)
10303            .build()
10304            .unwrap();
10305        runtime.block_on(async {
10306            let caller = std::thread::current().id();
10307            let worker = service
10308                .run_sql_read_operation(ReadConsistency::Local, || std::thread::current().id())
10309                .await
10310                .unwrap();
10311
10312            assert_eq!(worker, caller);
10313            assert_eq!(
10314                service
10315                    .sql_reads_in_flight
10316                    .load(std::sync::atomic::Ordering::Acquire),
10317                0
10318            );
10319        });
10320
10321        let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
10322            runtime.block_on(
10323                service.run_sql_read_operation(ReadConsistency::AppliedIndex(0), || -> () {
10324                    panic!("inline SQL read panic")
10325                }),
10326            )
10327        }));
10328        assert!(panic.is_err());
10329        assert_eq!(
10330            service
10331                .sql_reads_in_flight
10332                .load(std::sync::atomic::Ordering::Acquire),
10333            0
10334        );
10335    }
10336
10337    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10338    async fn node_service_adaptive_sql_read_offloads_overlap_and_recovers_join_error() {
10339        let (_dir, runtime) = sql_test_runtime();
10340        let service = NodeService::new(Arc::new(runtime), None);
10341        let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
10342        let (release_tx, release_rx) = std::sync::mpsc::channel();
10343        let first_service = service.clone();
10344        let first = tokio::spawn(async move {
10345            first_service
10346                .run_sql_read_operation(ReadConsistency::Local, move || {
10347                    entered_tx.send(()).unwrap();
10348                    release_rx.recv().unwrap();
10349                })
10350                .await
10351                .unwrap();
10352        });
10353        entered_rx.await.unwrap();
10354        assert_eq!(
10355            service
10356                .sql_reads_in_flight
10357                .load(std::sync::atomic::Ordering::Acquire),
10358            1
10359        );
10360
10361        let caller = std::thread::current().id();
10362        let worker = service
10363            .run_sql_read_operation(ReadConsistency::AppliedIndex(0), || {
10364                std::thread::current().id()
10365            })
10366            .await
10367            .unwrap();
10368        assert_ne!(worker, caller);
10369        assert_eq!(
10370            service
10371                .sql_reads_in_flight
10372                .load(std::sync::atomic::Ordering::Acquire),
10373            1
10374        );
10375
10376        let error = service
10377            .run_sql_read_operation(ReadConsistency::Local, || -> () {
10378                panic!("contended SQL read panic")
10379            })
10380            .await
10381            .unwrap_err();
10382        assert!(error.is_panic());
10383        assert_eq!(
10384            service
10385                .sql_reads_in_flight
10386                .load(std::sync::atomic::Ordering::Acquire),
10387            1
10388        );
10389
10390        release_tx.send(()).unwrap();
10391        first.await.unwrap();
10392        assert_eq!(
10393            service
10394                .sql_reads_in_flight
10395                .load(std::sync::atomic::Ordering::Acquire),
10396            0
10397        );
10398    }
10399
10400    #[test]
10401    fn node_service_adaptive_sql_read_offloads_on_current_thread() {
10402        let (_dir, node) = sql_test_runtime();
10403        let service = NodeService::new(Arc::new(node), None);
10404        let runtime = tokio::runtime::Builder::new_current_thread()
10405            .build()
10406            .unwrap();
10407        runtime.block_on(async {
10408            let caller = std::thread::current().id();
10409            let worker = service
10410                .run_sql_read_operation(ReadConsistency::Local, || std::thread::current().id())
10411                .await
10412                .unwrap();
10413
10414            assert_ne!(worker, caller);
10415            assert_eq!(
10416                service
10417                    .sql_reads_in_flight
10418                    .load(std::sync::atomic::Ordering::Acquire),
10419                0
10420            );
10421        });
10422    }
10423
10424    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10425    async fn node_service_read_barrier_offloads_without_counting_fast_reads() {
10426        let (_dir, runtime) = sql_test_runtime();
10427        let service = NodeService::new(Arc::new(runtime), None);
10428        let caller = std::thread::current().id();
10429        let worker = service
10430            .run_sql_read_operation(ReadConsistency::ReadBarrier, || std::thread::current().id())
10431            .await
10432            .unwrap();
10433
10434        assert_ne!(worker, caller);
10435        assert_eq!(
10436            service
10437                .sql_reads_in_flight
10438                .load(std::sync::atomic::Ordering::Acquire),
10439            0
10440        );
10441    }
10442
10443    #[test]
10444    fn embedded_sql_query_keeps_raw_budget_without_http_json_budget() {
10445        let (_dir, runtime) = sql_test_runtime();
10446        let response = runtime
10447            .query_sql(
10448                &SqlStatement {
10449                    sql: "SELECT replace(hex(zeroblob(700000)), '00', char(1)) AS value".into(),
10450                    parameters: Vec::new(),
10451                },
10452                ReadConsistency::Local,
10453                1,
10454            )
10455            .unwrap();
10456
10457        assert!(serde_json::to_vec(&response).unwrap().len() > MAX_SQL_RESPONSE_BYTES);
10458    }
10459
10460    #[test]
10461    fn sql_http_response_rejects_encoded_body_over_limit() {
10462        let response = SqlQueryResponse {
10463            columns: vec!["value".into()],
10464            rows: vec![vec![SqlValue::Text("\u{1}".repeat(700_000))]],
10465            applied_index: 0,
10466            hash: LogHash::ZERO,
10467        };
10468
10469        assert_eq!(
10470            sql_query_http_response(response).status(),
10471            axum::http::StatusCode::BAD_REQUEST
10472        );
10473    }
10474
10475    #[cfg(feature = "graph")]
10476    #[test]
10477    fn graph_response_work_holds_client_capacity_until_completion() {
10478        let slots = std::sync::Arc::new(tokio::sync::Semaphore::new(1));
10479        let permit = std::sync::Arc::new(slots.clone().try_acquire_owned().unwrap());
10480
10481        let capacity_exhausted_during_response =
10482            with_graph_client_permit(permit, || slots.clone().try_acquire_owned().is_err());
10483
10484        assert!(capacity_exhausted_during_response);
10485        assert!(slots.try_acquire().is_ok());
10486    }
10487
10488    #[cfg(feature = "graph")]
10489    #[test]
10490    fn graph_client_query_error_returns_400_without_latching_readiness() {
10491        let (_dir, runtime) = graph_test_runtime();
10492
10493        let error = runtime.map_graph_read_error(rhiza_graph::Error::InvalidCommand(
10494            "unknown property".into(),
10495        ));
10496        let response = node_error_response(error);
10497
10498        assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
10499        assert!(runtime.is_ready());
10500        assert!(!runtime.is_fatal());
10501    }
10502
10503    #[cfg(feature = "graph")]
10504    #[test]
10505    fn graph_resource_exhaustion_returns_503_without_latching_readiness() {
10506        let (_dir, runtime) = graph_test_runtime();
10507
10508        let error = runtime.map_graph_read_error(rhiza_graph::Error::ResourceExhausted(
10509            "buffer pool is full".into(),
10510        ));
10511        let response = node_error_response(error);
10512
10513        assert_eq!(
10514            response.status(),
10515            axum::http::StatusCode::SERVICE_UNAVAILABLE
10516        );
10517        assert!(runtime.is_ready());
10518        assert!(!runtime.is_fatal());
10519    }
10520
10521    #[cfg(feature = "kv")]
10522    #[test]
10523    fn kv_resource_exhaustion_returns_503_without_latching_readiness() {
10524        let (_dir, runtime) = kv_test_runtime();
10525
10526        let error = runtime.map_kv_read_error(rhiza_kv::Error::ResourceExhausted(
10527            "scan result is too large".into(),
10528        ));
10529        let response = node_error_response(error);
10530
10531        assert_eq!(
10532            response.status(),
10533            axum::http::StatusCode::SERVICE_UNAVAILABLE
10534        );
10535        assert!(runtime.is_ready());
10536        assert!(!runtime.is_fatal());
10537    }
10538
10539    #[cfg(feature = "graph")]
10540    #[test]
10541    fn graph_batch_coalesces_exact_retry_and_isolates_conflicting_duplicate() {
10542        let (_dir, runtime) = graph_test_runtime();
10543        let canonical =
10544            GraphCommandV1::put_document("same", "document", GraphValueV1::String("first".into()))
10545                .unwrap();
10546        let conflict = GraphCommandV1::put_document(
10547            "same",
10548            "document",
10549            GraphValueV1::String("conflict".into()),
10550        )
10551        .unwrap();
10552        let unrelated =
10553            GraphCommandV1::put_document("other", "other", GraphValueV1::U64(2)).unwrap();
10554        let results = runtime
10555            .mutate_graph_batch(vec![canonical.clone(), canonical, conflict, unrelated])
10556            .unwrap();
10557
10558        let canonical = results[0].as_ref().unwrap().applied_index();
10559        assert_eq!(results[1].as_ref().unwrap().applied_index(), canonical);
10560        assert!(matches!(
10561            results[2],
10562            Err(super::NodeError::InvalidRequest(_))
10563        ));
10564        assert_eq!(results[3].as_ref().unwrap().applied_index(), canonical);
10565        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
10566        assert!(runtime.is_ready());
10567    }
10568
10569    #[cfg(feature = "kv")]
10570    #[test]
10571    fn kv_batch_coalesces_exact_retry_and_isolates_conflicting_duplicate() {
10572        let (_dir, runtime) = kv_test_runtime();
10573        let canonical = KvCommandV1::put("same", b"key".to_vec(), b"first".to_vec()).unwrap();
10574        let conflict = KvCommandV1::put("same", b"key".to_vec(), b"conflict".to_vec()).unwrap();
10575        let unrelated = KvCommandV1::put("other", b"other".to_vec(), b"second".to_vec()).unwrap();
10576        let results = runtime
10577            .mutate_kv_batch(vec![canonical.clone(), canonical, conflict, unrelated])
10578            .unwrap();
10579
10580        let canonical = results[0].as_ref().unwrap().applied_index();
10581        assert_eq!(results[1].as_ref().unwrap().applied_index(), canonical);
10582        assert!(matches!(
10583            results[2],
10584            Err(super::NodeError::InvalidRequest(_))
10585        ));
10586        assert_eq!(results[3].as_ref().unwrap().applied_index(), canonical);
10587        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
10588        assert!(runtime.is_ready());
10589    }
10590
10591    #[cfg(feature = "kv")]
10592    #[test]
10593    fn kv_group_commit_coalesces_four_waiting_64_member_calls_into_one_qlog() {
10594        let (_dir, runtime) = kv_test_runtime();
10595        let runtime = Arc::new(runtime);
10596        let commit = runtime.lock_commit().unwrap();
10597        let start = Arc::new(Barrier::new(5));
10598        let workers = (0..4)
10599            .map(|call| {
10600                let runtime = Arc::clone(&runtime);
10601                let start = Arc::clone(&start);
10602                std::thread::spawn(move || {
10603                    let commands = (0..64)
10604                        .map(|member| {
10605                            let id = call * 64 + member;
10606                            KvCommandV1::put(
10607                                format!("kv-group-{id}"),
10608                                format!("key-{id}").into_bytes(),
10609                                vec![u8::try_from(call).unwrap(); 128],
10610                            )
10611                            .unwrap()
10612                        })
10613                        .collect();
10614                    start.wait();
10615                    runtime.mutate_kv_batch(commands)
10616                })
10617            })
10618            .collect::<Vec<_>>();
10619        start.wait();
10620        runtime
10621            .kv_group_commit
10622            .wait_for_pending_calls(4, Duration::from_secs(5));
10623        drop(commit);
10624
10625        let responses = workers
10626            .into_iter()
10627            .map(|worker| worker.join().unwrap().unwrap())
10628            .collect::<Vec<_>>();
10629        let anchors = responses
10630            .iter()
10631            .flatten()
10632            .map(|result| {
10633                let outcome = result.as_ref().unwrap();
10634                (outcome.applied_index(), outcome.hash())
10635            })
10636            .collect::<std::collections::HashSet<_>>();
10637        assert_eq!(anchors.len(), 1);
10638        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
10639    }
10640
10641    #[cfg(feature = "kv")]
10642    #[test]
10643    fn kv_group_commit_rejects_public_257_member_call_before_writing() {
10644        let (_dir, runtime) = kv_test_runtime();
10645        let commands = (0..257)
10646            .map(|id| {
10647                KvCommandV1::put(
10648                    format!("kv-over-{id}"),
10649                    format!("key-{id}").into_bytes(),
10650                    b"value".to_vec(),
10651                )
10652                .unwrap()
10653            })
10654            .collect();
10655
10656        let error = runtime.mutate_kv_batch(commands).unwrap_err();
10657
10658        assert!(matches!(error, NodeError::InvalidRequest(_)));
10659        assert_eq!(runtime.log_store().last_index().unwrap(), None);
10660        assert!(runtime
10661            .kv_group_commit
10662            .state
10663            .lock()
10664            .unwrap()
10665            .pending
10666            .is_empty());
10667    }
10668
10669    #[cfg(feature = "kv")]
10670    #[test]
10671    fn kv_group_commit_lone_call_completes_and_leaves_queue_idle() {
10672        let (_dir, runtime) = kv_test_runtime();
10673
10674        let outcome = runtime
10675            .mutate_kv(KvCommandV1::put("kv-lone", b"key".to_vec(), b"value".to_vec()).unwrap())
10676            .unwrap();
10677
10678        assert_eq!(outcome.applied_index(), 1);
10679        let state = runtime
10680            .kv_group_commit
10681            .state
10682            .lock()
10683            .unwrap_or_else(std::sync::PoisonError::into_inner);
10684        assert!(state.pending.is_empty());
10685        assert_eq!(state.pending_encoded_bytes, 0);
10686        assert!(!state.leader_active);
10687    }
10688
10689    #[cfg(feature = "kv")]
10690    #[test]
10691    fn kv_group_commit_shutdown_wakes_waiters_without_writing() {
10692        let (_dir, runtime) = kv_test_runtime();
10693        let runtime = Arc::new(runtime);
10694        let commit = runtime.lock_commit().unwrap();
10695        let start = Arc::new(Barrier::new(3));
10696        let workers = (0..2)
10697            .map(|id| {
10698                let runtime = Arc::clone(&runtime);
10699                let start = Arc::clone(&start);
10700                std::thread::spawn(move || {
10701                    start.wait();
10702                    runtime.mutate_kv(
10703                        KvCommandV1::put(
10704                            format!("kv-shutdown-{id}"),
10705                            format!("key-{id}").into_bytes(),
10706                            b"value".to_vec(),
10707                        )
10708                        .unwrap(),
10709                    )
10710                })
10711            })
10712            .collect::<Vec<_>>();
10713        start.wait();
10714        runtime
10715            .kv_group_commit
10716            .wait_for_pending_calls(2, Duration::from_secs(5));
10717
10718        runtime.cancel_operations();
10719        drop(commit);
10720
10721        for worker in workers {
10722            assert!(matches!(
10723                worker.join().unwrap(),
10724                Err(NodeError::Unavailable(_))
10725            ));
10726        }
10727        assert_eq!(runtime.log_store().last_index().unwrap(), None);
10728        let state = runtime
10729            .kv_group_commit
10730            .state
10731            .lock()
10732            .unwrap_or_else(std::sync::PoisonError::into_inner);
10733        assert!(state.pending.is_empty());
10734        assert_eq!(state.pending_encoded_bytes, 0);
10735        assert!(!state.leader_active);
10736    }
10737
10738    #[cfg(feature = "kv")]
10739    #[test]
10740    fn kv_group_commit_preserves_cross_call_retry_conflict_and_new_result_offsets() {
10741        let (_dir, runtime) = kv_test_runtime();
10742        let runtime = Arc::new(runtime);
10743        let stored = KvCommandV1::put(
10744            "kv-stored",
10745            b"stored-key".to_vec(),
10746            b"stored-value".to_vec(),
10747        )
10748        .unwrap();
10749        let stored_outcome = runtime.mutate_kv(stored.clone()).unwrap();
10750        let conflict =
10751            KvCommandV1::put("kv-stored", b"stored-key".to_vec(), b"conflict".to_vec()).unwrap();
10752        let commit = runtime.lock_commit().unwrap();
10753        let start = Arc::new(Barrier::new(3));
10754        let retry_worker = {
10755            let runtime = Arc::clone(&runtime);
10756            let start = Arc::clone(&start);
10757            std::thread::spawn(move || {
10758                start.wait();
10759                runtime.mutate_kv_batch(vec![stored, conflict])
10760            })
10761        };
10762        let new_worker = {
10763            let runtime = Arc::clone(&runtime);
10764            let start = Arc::clone(&start);
10765            std::thread::spawn(move || {
10766                start.wait();
10767                runtime.mutate_kv_batch(vec![
10768                    KvCommandV1::put("kv-new-1", b"new-1".to_vec(), b"one".to_vec()).unwrap(),
10769                    KvCommandV1::put("kv-new-2", b"new-2".to_vec(), b"two".to_vec()).unwrap(),
10770                ])
10771            })
10772        };
10773        start.wait();
10774        runtime
10775            .kv_group_commit
10776            .wait_for_pending_calls(2, Duration::from_secs(5));
10777        drop(commit);
10778
10779        let retry_results = retry_worker.join().unwrap().unwrap();
10780        let new_results = new_worker.join().unwrap().unwrap();
10781
10782        assert_eq!(
10783            retry_results[0].as_ref().unwrap().applied_index(),
10784            stored_outcome.applied_index()
10785        );
10786        assert!(matches!(
10787            retry_results[1],
10788            Err(NodeError::InvalidRequest(_))
10789        ));
10790        let new_anchors = new_results
10791            .iter()
10792            .map(|result| {
10793                let outcome = result.as_ref().unwrap();
10794                (outcome.applied_index(), outcome.hash())
10795            })
10796            .collect::<std::collections::HashSet<_>>();
10797        assert_eq!(new_anchors.len(), 1);
10798        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
10799    }
10800
10801    #[cfg(feature = "kv")]
10802    #[test]
10803    fn kv_group_commit_releases_pending_byte_budget_after_drain_and_failure() {
10804        let queue = super::KvGroupCommitQueue::new();
10805        let cancelled = AtomicBool::new(false);
10806        let member = |id: usize, bytes: usize| super::RuntimeBatchMember {
10807            #[cfg(feature = "sql")]
10808            request_id: format!("kv-byte-{id}"),
10809            payload: vec![u8::try_from(id).unwrap_or_default(); bytes],
10810            operation: super::QueuedOperation::Kv(
10811                KvCommandV1::put(
10812                    format!("kv-byte-{id}"),
10813                    format!("key-{id}").into_bytes(),
10814                    b"value".to_vec(),
10815                )
10816                .unwrap(),
10817            ),
10818        };
10819        for id in 0..63 {
10820            queue
10821                .enqueue(vec![member(id, MAX_COMMAND_BYTES)], &cancelled)
10822                .unwrap();
10823        }
10824
10825        let overflow = match queue.enqueue(vec![member(63, MAX_COMMAND_BYTES * 2)], &cancelled) {
10826            Ok(_) => panic!("pending KV byte budget must reject oversized aggregate work"),
10827            Err(error) => error,
10828        };
10829        assert!(matches!(overflow, NodeError::ResourceExhausted(_)));
10830
10831        let drained = queue.drain_next_group().unwrap();
10832        assert_eq!(drained.len(), 63);
10833        let released = queue
10834            .enqueue(vec![member(64, MAX_COMMAND_BYTES)], &cancelled)
10835            .unwrap()
10836            .0;
10837        queue.fail_pending(NodeError::Unavailable("test failure".into()));
10838        assert!(matches!(
10839            released.wait(&cancelled),
10840            Err(NodeError::Unavailable(_))
10841        ));
10842        let state = queue
10843            .state
10844            .lock()
10845            .unwrap_or_else(std::sync::PoisonError::into_inner);
10846        assert!(state.pending.is_empty());
10847        assert_eq!(state.pending_encoded_bytes, 0);
10848        assert!(!state.leader_active);
10849    }
10850
10851    #[cfg(feature = "kv")]
10852    #[test]
10853    fn kv_group_commit_window_restarts_after_staggered_enqueue() {
10854        let queue = Arc::new(super::KvGroupCommitQueue::new());
10855        let cancelled = AtomicBool::new(false);
10856        let member = |id: usize| super::RuntimeBatchMember {
10857            #[cfg(feature = "sql")]
10858            request_id: format!("kv-debounce-{id}"),
10859            payload: vec![u8::try_from(id).unwrap_or_default()],
10860            operation: super::QueuedOperation::Kv(
10861                KvCommandV1::put(
10862                    format!("kv-debounce-{id}"),
10863                    format!("key-{id}").into_bytes(),
10864                    b"value".to_vec(),
10865                )
10866                .unwrap(),
10867            ),
10868        };
10869        queue.enqueue(vec![member(1)], &cancelled).unwrap();
10870        let collector = Arc::clone(&queue);
10871        let (finished, receive) = std::sync::mpsc::channel();
10872        let worker = std::thread::spawn(move || {
10873            let collected = collector.collect_until_full_or_timeout(Duration::from_millis(100));
10874            finished.send(collected).unwrap();
10875        });
10876
10877        std::thread::sleep(Duration::from_millis(75));
10878        queue.enqueue(vec![member(2)], &cancelled).unwrap();
10879        std::thread::sleep(Duration::from_millis(75));
10880        queue.enqueue(vec![member(3)], &cancelled).unwrap();
10881        assert!(receive.recv_timeout(Duration::from_millis(50)).is_err());
10882        assert!(receive.recv_timeout(Duration::from_millis(150)).unwrap());
10883        worker.join().unwrap();
10884        assert_eq!(queue.drain_next_group().unwrap().len(), 3);
10885    }
10886
10887    #[cfg(feature = "kv")]
10888    #[test]
10889    fn kv_group_commit_returns_committed_group_when_cancelled_after_execution() {
10890        let (_dir, mut runtime) = kv_test_runtime();
10891        runtime.kv_group_commit_after_execute_hook = Some(Arc::new(NodeRuntime::cancel_operations));
10892        let runtime = Arc::new(runtime);
10893        let commit = runtime.lock_commit().unwrap();
10894        let start = Arc::new(Barrier::new(3));
10895        let workers = (0..2)
10896            .map(|call| {
10897                let runtime = Arc::clone(&runtime);
10898                let start = Arc::clone(&start);
10899                std::thread::spawn(move || {
10900                    let commands = (0..64)
10901                        .map(|member| {
10902                            let id = call * 64 + member;
10903                            KvCommandV1::put(
10904                                format!("kv-cancel-after-{id}"),
10905                                format!("key-{id}").into_bytes(),
10906                                b"value".to_vec(),
10907                            )
10908                            .unwrap()
10909                        })
10910                        .collect();
10911                    start.wait();
10912                    runtime.mutate_kv_batch(commands)
10913                })
10914            })
10915            .collect::<Vec<_>>();
10916        start.wait();
10917        runtime
10918            .kv_group_commit
10919            .wait_for_pending_calls(2, Duration::from_secs(5));
10920        drop(commit);
10921
10922        let results = workers
10923            .into_iter()
10924            .flat_map(|worker| worker.join().unwrap().unwrap())
10925            .collect::<Vec<_>>();
10926
10927        assert_eq!(results.len(), 128);
10928        let anchors = results
10929            .iter()
10930            .map(|result| {
10931                let outcome = result.as_ref().unwrap();
10932                (outcome.applied_index(), outcome.hash())
10933            })
10934            .collect::<std::collections::HashSet<_>>();
10935        assert_eq!(anchors.len(), 1);
10936        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
10937        assert!(runtime.operation_cancelled.load(Ordering::Acquire));
10938    }
10939
10940    #[cfg(feature = "kv")]
10941    #[test]
10942    fn kv_largest_fitting_prefix_is_exact_for_large_grouped_batch() {
10943        let commands = (0..256)
10944            .map(|id| {
10945                KvCommandV1::put(
10946                    format!("kv-prefix-{id:04}"),
10947                    format!("key-{id:04}").into_bytes(),
10948                    vec![b'x'; 4 * 1024],
10949                )
10950                .unwrap()
10951            })
10952            .collect::<Vec<_>>();
10953        assert!(super::encode_replicated_kv_batch(&commands).unwrap().len() > MAX_COMMAND_BYTES);
10954        let expected = (2..commands.len())
10955            .filter(|count| {
10956                super::encode_replicated_kv_batch(&commands[..*count])
10957                    .unwrap()
10958                    .len()
10959                    <= MAX_COMMAND_BYTES
10960            })
10961            .max()
10962            .unwrap();
10963
10964        let (count, payload) = super::largest_fitting_kv_batch_prefix(&commands).unwrap();
10965
10966        assert_eq!(count, expected);
10967        assert!(payload.len() <= MAX_COMMAND_BYTES);
10968        assert!(
10969            super::encode_replicated_kv_batch(&commands[..count + 1])
10970                .unwrap()
10971                .len()
10972                > MAX_COMMAND_BYTES
10973        );
10974    }
10975
10976    #[cfg(feature = "kv")]
10977    #[test]
10978    fn kv_group_commit_large_batch_uses_largest_fitting_fifo_sub_batches() {
10979        let (_dir, runtime) = kv_test_runtime();
10980        let runtime = Arc::new(runtime);
10981        let calls = (0..4)
10982            .map(|call| {
10983                (0..64)
10984                    .map(|member| {
10985                        let id = call * 64 + member;
10986                        KvCommandV1::put(
10987                            format!("kv-large-{id:04}"),
10988                            format!("key-{id:04}").into_bytes(),
10989                            vec![b'x'; 4 * 1024],
10990                        )
10991                        .unwrap()
10992                    })
10993                    .collect::<Vec<_>>()
10994            })
10995            .collect::<Vec<_>>();
10996        let flattened = calls.iter().flatten().cloned().collect::<Vec<_>>();
10997        let (largest_prefix, _) = super::largest_fitting_kv_batch_prefix(&flattened).unwrap();
10998        let expected_entries = flattened.len().div_ceil(largest_prefix);
10999        let commit = runtime.lock_commit().unwrap();
11000        let start = Arc::new(Barrier::new(5));
11001        let workers = calls
11002            .into_iter()
11003            .map(|commands| {
11004                let runtime = Arc::clone(&runtime);
11005                let start = Arc::clone(&start);
11006                std::thread::spawn(move || {
11007                    start.wait();
11008                    runtime.mutate_kv_batch(commands)
11009                })
11010            })
11011            .collect::<Vec<_>>();
11012        start.wait();
11013        runtime
11014            .kv_group_commit
11015            .wait_for_pending_calls(4, Duration::from_secs(5));
11016        drop(commit);
11017
11018        let mut counts = std::collections::BTreeMap::new();
11019        for result in workers
11020            .into_iter()
11021            .flat_map(|worker| worker.join().unwrap().unwrap())
11022        {
11023            *counts
11024                .entry(result.unwrap().applied_index())
11025                .or_insert(0_usize) += 1;
11026        }
11027
11028        assert_eq!(counts.len(), expected_entries);
11029        let counts = counts.into_values().collect::<Vec<_>>();
11030        assert!(counts[..counts.len() - 1]
11031            .iter()
11032            .all(|count| *count == largest_prefix));
11033        assert_eq!(counts.iter().sum::<usize>(), 256);
11034        assert_eq!(
11035            runtime.log_store().last_index().unwrap(),
11036            Some(u64::try_from(expected_entries).unwrap())
11037        );
11038        for index in 1..=u64::try_from(expected_entries).unwrap() {
11039            assert!(runtime
11040                .log_store()
11041                .read(index)
11042                .unwrap()
11043                .is_some_and(|entry| entry.payload.len() <= MAX_COMMAND_BYTES));
11044        }
11045    }
11046
11047    #[cfg(feature = "kv")]
11048    #[test]
11049    fn kv_group_commit_leader_panic_wakes_active_and_pending_calls_with_same_fatal() {
11050        let (_dir, mut runtime) = kv_test_runtime();
11051        runtime.kv_group_commit_before_execute_hook =
11052            Some(Arc::new(|| panic!("injected KV group leader panic")));
11053        let runtime = Arc::new(runtime);
11054        let commit = runtime.lock_commit().unwrap();
11055        let mut workers = Vec::new();
11056        for call in 0..17 {
11057            let worker_runtime = Arc::clone(&runtime);
11058            workers.push(std::thread::spawn(move || {
11059                worker_runtime.mutate_kv_batch(
11060                    (0..64)
11061                        .map(|member| {
11062                            let id = call * 64 + member;
11063                            KvCommandV1::put(
11064                                format!("kv-panic-{id}"),
11065                                format!("key-{id}").into_bytes(),
11066                                b"value".to_vec(),
11067                            )
11068                            .unwrap()
11069                        })
11070                        .collect(),
11071                )
11072            }));
11073            runtime
11074                .kv_group_commit
11075                .wait_for_pending_calls(call + 1, Duration::from_secs(5));
11076        }
11077        drop(commit);
11078
11079        let errors = workers
11080            .into_iter()
11081            .map(|worker| worker.join().unwrap().unwrap_err())
11082            .collect::<Vec<_>>();
11083        assert!(errors
11084            .iter()
11085            .all(|error| matches!(error, NodeError::Fatal(_))));
11086        assert_eq!(errors[0].to_string(), errors[1].to_string());
11087        assert!(runtime.is_fatal());
11088        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11089    }
11090
11091    #[cfg(feature = "kv")]
11092    #[test]
11093    fn kv_group_commit_reopens_all_four_grouped_calls_at_shared_durable_anchor() {
11094        let (dir, runtime) = kv_test_runtime();
11095        let runtime = Arc::new(runtime);
11096        let commit = runtime.lock_commit().unwrap();
11097        let start = Arc::new(Barrier::new(5));
11098        let workers = (0..4)
11099            .map(|call| {
11100                let runtime = Arc::clone(&runtime);
11101                let start = Arc::clone(&start);
11102                std::thread::spawn(move || {
11103                    let commands = (0..64)
11104                        .map(|member| {
11105                            let id = call * 64 + member;
11106                            KvCommandV1::put(
11107                                format!("kv-reopen-{id}"),
11108                                format!("key-{id:04}").into_bytes(),
11109                                vec![u8::try_from(call).unwrap(); 128],
11110                            )
11111                            .unwrap()
11112                        })
11113                        .collect();
11114                    start.wait();
11115                    runtime.mutate_kv_batch(commands)
11116                })
11117            })
11118            .collect::<Vec<_>>();
11119        start.wait();
11120        runtime
11121            .kv_group_commit
11122            .wait_for_pending_calls(4, Duration::from_secs(5));
11123        drop(commit);
11124
11125        let results = workers
11126            .into_iter()
11127            .flat_map(|worker| worker.join().unwrap().unwrap())
11128            .collect::<Vec<_>>();
11129        let anchors = results
11130            .iter()
11131            .map(|result| {
11132                let outcome = result.as_ref().unwrap();
11133                (outcome.applied_index(), outcome.hash())
11134            })
11135            .collect::<std::collections::HashSet<_>>();
11136        let [(applied_index, applied_hash)] = anchors.into_iter().collect::<Vec<_>>()[..] else {
11137            panic!("four grouped calls must share one durable anchor");
11138        };
11139        assert_eq!(
11140            runtime.log_store().last_index().unwrap(),
11141            Some(applied_index)
11142        );
11143        let config = runtime.config().clone();
11144        drop(runtime);
11145
11146        let consensus = Arc::new(
11147            ThreeNodeConsensus::from_recovered_tip(
11148                config.cluster_id().to_owned(),
11149                config.node_id().to_owned(),
11150                config.epoch(),
11151                config.config_id(),
11152                [
11153                    dir.path().join("recorders/n1"),
11154                    dir.path().join("recorders/n2"),
11155                    dir.path().join("recorders/n3"),
11156                ],
11157                applied_index + 1,
11158                applied_hash,
11159            )
11160            .unwrap(),
11161        );
11162        let reopened = NodeRuntime::open(config, consensus, &[]).unwrap();
11163
11164        assert_eq!(reopened.applied_index().unwrap(), applied_index);
11165        assert_eq!(reopened.applied_hash().unwrap(), applied_hash);
11166        assert_eq!(reopened.log_store().last_index().unwrap(), Some(1));
11167        for id in 0..256 {
11168            let response = reopened
11169                .get_kv(format!("key-{id:04}").as_bytes(), ReadConsistency::Local)
11170                .unwrap();
11171            assert_eq!(
11172                response.value,
11173                Some(vec![u8::try_from(id / 64).unwrap(); 128])
11174            );
11175            assert_eq!(response.applied_index, applied_index);
11176            assert_eq!(response.hash, applied_hash);
11177        }
11178    }
11179
11180    #[test]
11181    fn sql_batch_preflight_rejects_entire_vector_without_growing_log() {
11182        let (_dir, runtime) = sql_test_runtime();
11183        let valid = SqlCommand {
11184            request_id: "valid".into(),
11185            statements: vec![SqlStatement {
11186                sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY)".into(),
11187                parameters: vec![],
11188            }],
11189        };
11190        let invalid = SqlCommand {
11191            request_id: String::new(),
11192            statements: valid.statements.clone(),
11193        };
11194
11195        let error = runtime.execute_sql_batch(vec![valid, invalid]).unwrap_err();
11196
11197        assert!(matches!(error, NodeError::InvalidRequest(_)));
11198        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11199    }
11200
11201    #[test]
11202    fn sql_batch_rejects_aggregate_encoded_input_over_command_cap_before_io() {
11203        let (_dir, runtime) = sql_test_runtime();
11204        let command = |request_id: &str, fill: char| SqlCommand {
11205            request_id: request_id.into(),
11206            statements: vec![SqlStatement {
11207                sql: "SELECT ?1".into(),
11208                parameters: vec![SqlValue::Text(
11209                    std::iter::repeat_n(fill, MAX_COMMAND_BYTES / 2).collect(),
11210                )],
11211            }],
11212        };
11213
11214        let error = runtime
11215            .execute_sql_batch(vec![
11216                command("aggregate-a", 'a'),
11217                command("aggregate-b", 'b'),
11218            ])
11219            .unwrap_err();
11220
11221        assert!(matches!(error, NodeError::ResourceExhausted(_)));
11222        assert_eq!(runtime.log_store().last_index().unwrap(), None);
11223        assert!(runtime
11224            .sql_group_commit
11225            .state
11226            .lock()
11227            .unwrap()
11228            .pending
11229            .is_empty());
11230    }
11231
11232    #[test]
11233    fn sql_write_profiling_records_nothing_when_observer_is_not_installed() {
11234        let profiler = SqlWriteProfiler::new(8);
11235        let (_dir, runtime) = sql_test_runtime();
11236
11237        runtime
11238            .execute_sql(SqlCommand {
11239                request_id: "schema".into(),
11240                statements: vec![SqlStatement {
11241                    sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11242                    parameters: vec![],
11243                }],
11244            })
11245            .unwrap();
11246
11247        assert!(runtime.config().sql_write_profiler().is_none());
11248        assert!(profiler.snapshot().samples.is_empty());
11249    }
11250
11251    #[test]
11252    fn sql_write_profiling_records_one_consistent_sample_for_one_physical_batch() {
11253        let profiler = SqlWriteProfiler::new(8);
11254        let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11255        runtime
11256            .execute_sql(SqlCommand {
11257                request_id: "schema".into(),
11258                statements: vec![SqlStatement {
11259                    sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11260                    parameters: vec![],
11261                }],
11262            })
11263            .unwrap();
11264        profiler.drain();
11265
11266        let commands = (1..=3)
11267            .map(|id| SqlCommand {
11268                request_id: format!("insert-{id}"),
11269                statements: vec![SqlStatement {
11270                    sql: "INSERT INTO profiled_items(id) VALUES (?1)".into(),
11271                    parameters: vec![SqlValue::Integer(id)],
11272                }],
11273            })
11274            .collect();
11275        let responses = runtime.execute_sql_batch(commands).unwrap();
11276
11277        let snapshot = profiler.snapshot();
11278        assert_eq!(snapshot.dropped_samples, 0);
11279        let [sample] = snapshot.samples.as_slice() else {
11280            panic!("one physical SQL batch must emit one sample: {snapshot:?}");
11281        };
11282        assert_eq!(sample.batch_member_count, 3);
11283        assert_eq!(
11284            sample.total_service_us,
11285            sample
11286                .commit_lock_wait_us
11287                .saturating_add(sample.precheck_classification_us)
11288                .saturating_add(sample.qwal_prepare_us)
11289                .saturating_add(sample.consensus_propose_us)
11290                .saturating_add(sample.local_qlog_mirror_append_us)
11291                .saturating_add(sample.sql_materializer_apply_us)
11292                .saturating_add(sample.response_other_total_us)
11293        );
11294        let (applied_index, applied_hash) = runtime.ensure_materialized_tip().unwrap();
11295        assert!(responses.iter().all(|response| {
11296            response.as_ref().is_ok_and(|response| {
11297                response.applied_index == applied_index && response.hash == applied_hash
11298            })
11299        }));
11300    }
11301
11302    #[test]
11303    fn sql_write_profiling_does_not_fabricate_sample_for_failed_batch() {
11304        let profiler = SqlWriteProfiler::new(8);
11305        let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11306        runtime
11307            .execute_sql(SqlCommand {
11308                request_id: "schema".into(),
11309                statements: vec![SqlStatement {
11310                    sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11311                    parameters: vec![],
11312                }],
11313            })
11314            .unwrap();
11315        runtime
11316            .execute_sql(SqlCommand {
11317                request_id: "first".into(),
11318                statements: vec![SqlStatement {
11319                    sql: "INSERT INTO profiled_items(id) VALUES (1)".into(),
11320                    parameters: vec![],
11321                }],
11322            })
11323            .unwrap();
11324        profiler.drain();
11325        let last_index = runtime.log_store().last_index().unwrap();
11326
11327        let error = runtime
11328            .execute_sql(SqlCommand {
11329                request_id: "duplicate".into(),
11330                statements: vec![SqlStatement {
11331                    sql: "INSERT INTO profiled_items(id) VALUES (1)".into(),
11332                    parameters: vec![],
11333                }],
11334            })
11335            .unwrap_err();
11336
11337        assert!(matches!(error, NodeError::InvalidSqlStatement { .. }));
11338        assert!(profiler.snapshot().samples.is_empty());
11339        assert_eq!(runtime.log_store().last_index().unwrap(), last_index);
11340    }
11341
11342    #[test]
11343    fn sql_group_commit_coalesces_four_waiting_typed_calls_into_one_1024_receipt_qwal() {
11344        let profiler = SqlWriteProfiler::new(8);
11345        let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11346        let runtime = Arc::new(runtime);
11347        runtime
11348            .execute_sql(SqlCommand {
11349                request_id: "group-schema".into(),
11350                statements: vec![SqlStatement {
11351                    sql: "CREATE TABLE grouped_items(id INTEGER PRIMARY KEY)".into(),
11352                    parameters: vec![],
11353                }],
11354            })
11355            .unwrap();
11356        profiler.drain();
11357
11358        let commit = runtime.lock_commit().unwrap();
11359        let start = Arc::new(Barrier::new(5));
11360        let workers = (0..4)
11361            .map(|call| {
11362                let runtime = Arc::clone(&runtime);
11363                let start = Arc::clone(&start);
11364                std::thread::spawn(move || {
11365                    let commands = (0..256)
11366                        .map(|offset| {
11367                            let id = call * 256 + offset;
11368                            SqlCommand {
11369                                request_id: format!("group-{id}"),
11370                                statements: vec![SqlStatement {
11371                                    sql: "INSERT INTO grouped_items(id) VALUES (?1)".into(),
11372                                    parameters: vec![SqlValue::Integer(id)],
11373                                }],
11374                            }
11375                        })
11376                        .collect();
11377                    start.wait();
11378                    runtime.execute_sql_batch(commands).unwrap()
11379                })
11380            })
11381            .collect::<Vec<_>>();
11382        start.wait();
11383        runtime
11384            .sql_group_commit
11385            .wait_for_pending_calls(4, Duration::from_secs(5));
11386        drop(commit);
11387
11388        let results = workers
11389            .into_iter()
11390            .flat_map(|worker| worker.join().unwrap())
11391            .collect::<Vec<_>>();
11392        assert_eq!(results.len(), 1024);
11393        let first = results[0].as_ref().unwrap();
11394        assert!(results.iter().all(|result| {
11395            result.as_ref().is_ok_and(|result| {
11396                result.applied_index == first.applied_index && result.hash == first.hash
11397            })
11398        }));
11399        let entry = runtime
11400            .log_store()
11401            .read(first.applied_index)
11402            .unwrap()
11403            .unwrap();
11404        assert_eq!(
11405            rhiza_sql::decode_qwal_v3(&entry.payload)
11406                .unwrap()
11407                .receipts
11408                .len(),
11409            1024
11410        );
11411        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11412        let snapshot = profiler.snapshot();
11413        let [sample] = snapshot.samples.as_slice() else {
11414            panic!("one grouped physical commit must emit one sample: {snapshot:?}");
11415        };
11416        assert_eq!(sample.batch_member_count, 1024);
11417    }
11418
11419    #[test]
11420    fn sql_group_commit_keeps_fifth_whole_call_for_the_next_physical_group() {
11421        let (_dir, runtime) = sql_test_runtime();
11422        let runtime = Arc::new(runtime);
11423        runtime
11424            .execute_sql(SqlCommand {
11425                request_id: "next-group-schema".into(),
11426                statements: vec![SqlStatement {
11427                    sql: "CREATE TABLE next_group_items(id INTEGER PRIMARY KEY)".into(),
11428                    parameters: vec![],
11429                }],
11430            })
11431            .unwrap();
11432
11433        let commit = runtime.lock_commit().unwrap();
11434        let mut workers = Vec::new();
11435        for call in 0..5 {
11436            let worker_runtime = Arc::clone(&runtime);
11437            workers.push(std::thread::spawn(move || {
11438                worker_runtime
11439                    .execute_sql_batch(
11440                        (0..256)
11441                            .map(|offset| {
11442                                let id = call * 256 + offset;
11443                                SqlCommand {
11444                                    request_id: format!("next-group-{id}"),
11445                                    statements: vec![SqlStatement {
11446                                        sql: "INSERT INTO next_group_items(id) VALUES (?1)".into(),
11447                                        parameters: vec![SqlValue::Integer(id)],
11448                                    }],
11449                                }
11450                            })
11451                            .collect(),
11452                    )
11453                    .unwrap()
11454            }));
11455            runtime
11456                .sql_group_commit
11457                .wait_for_pending_calls(call as usize + 1, Duration::from_secs(5));
11458        }
11459        drop(commit);
11460
11461        let calls = workers
11462            .into_iter()
11463            .map(|worker| worker.join().unwrap())
11464            .collect::<Vec<_>>();
11465        for call in &calls[..4] {
11466            assert!(call
11467                .iter()
11468                .all(|result| result.as_ref().unwrap().applied_index == 2));
11469        }
11470        assert!(calls[4]
11471            .iter()
11472            .all(|result| result.as_ref().unwrap().applied_index == 3));
11473        assert_eq!(
11474            rhiza_sql::decode_qwal_v3(&runtime.log_store().read(2).unwrap().unwrap().payload)
11475                .unwrap()
11476                .receipts
11477                .len(),
11478            1024
11479        );
11480        assert_eq!(
11481            rhiza_sql::decode_qwal_v3(&runtime.log_store().read(3).unwrap().unwrap().payload)
11482                .unwrap()
11483                .receipts
11484                .len(),
11485            256
11486        );
11487    }
11488
11489    #[test]
11490    fn sql_group_commit_preserves_fifo_call_offsets_for_retries_conflicts_aliases_and_failures() {
11491        let (_dir, runtime) = sql_test_runtime();
11492        let runtime = Arc::new(runtime);
11493        runtime
11494            .execute_sql(SqlCommand {
11495                request_id: "fifo-schema".into(),
11496                statements: vec![SqlStatement {
11497                    sql: "CREATE TABLE fifo_items(id INTEGER PRIMARY KEY, value TEXT UNIQUE)"
11498                        .into(),
11499                    parameters: vec![],
11500                }],
11501            })
11502            .unwrap();
11503        let stored_command = SqlCommand {
11504            request_id: "fifo-stored".into(),
11505            statements: vec![SqlStatement {
11506                sql: "INSERT INTO fifo_items(id, value) VALUES (1, 'stored')".into(),
11507                parameters: vec![],
11508            }],
11509        };
11510        let stored = runtime.execute_sql(stored_command.clone()).unwrap();
11511        let valid_alias = SqlCommand {
11512            request_id: "fifo-alias".into(),
11513            statements: vec![SqlStatement {
11514                sql: "INSERT INTO fifo_items(id, value) VALUES (2, 'alias')".into(),
11515                parameters: vec![],
11516            }],
11517        };
11518        let conflict = SqlCommand {
11519            request_id: stored_command.request_id.clone(),
11520            statements: vec![SqlStatement {
11521                sql: "INSERT INTO fifo_items(id, value) VALUES (3, 'conflict')".into(),
11522                parameters: vec![],
11523            }],
11524        };
11525        let failed = SqlCommand {
11526            request_id: "fifo-failed".into(),
11527            statements: vec![SqlStatement {
11528                sql: "INSERT INTO fifo_items(id, value) VALUES (4, 'stored')".into(),
11529                parameters: vec![],
11530            }],
11531        };
11532        let valid = SqlCommand {
11533            request_id: "fifo-valid".into(),
11534            statements: vec![SqlStatement {
11535                sql: "INSERT INTO fifo_items(id, value) VALUES (5, 'valid')".into(),
11536                parameters: vec![],
11537            }],
11538        };
11539
11540        let commit = runtime.lock_commit().unwrap();
11541        let first_runtime = Arc::clone(&runtime);
11542        let first = std::thread::spawn(move || {
11543            first_runtime
11544                .execute_sql_batch(vec![
11545                    stored_command,
11546                    conflict,
11547                    valid_alias.clone(),
11548                    valid_alias,
11549                ])
11550                .unwrap()
11551        });
11552        runtime
11553            .sql_group_commit
11554            .wait_for_pending_calls(1, Duration::from_secs(5));
11555        let second_runtime = Arc::clone(&runtime);
11556        let second = std::thread::spawn(move || {
11557            second_runtime
11558                .execute_sql_batch(vec![failed, valid])
11559                .unwrap()
11560        });
11561        runtime
11562            .sql_group_commit
11563            .wait_for_pending_calls(2, Duration::from_secs(5));
11564        drop(commit);
11565
11566        let first = first.join().unwrap();
11567        let second = second.join().unwrap();
11568        assert_eq!(
11569            first[0].as_ref().unwrap().applied_index,
11570            stored.applied_index
11571        );
11572        assert!(matches!(first[1], Err(NodeError::RequestConflict(_))));
11573        assert_eq!(first[2], first[3]);
11574        assert_eq!(first[2].as_ref().unwrap().applied_index, 3);
11575        assert!(matches!(
11576            second[0],
11577            Err(NodeError::InvalidSqlStatement { .. })
11578        ));
11579        assert_eq!(second[1].as_ref().unwrap().applied_index, 3);
11580    }
11581
11582    #[test]
11583    fn sql_group_commit_rejects_overload_before_enqueue_without_orphaning_the_leader() {
11584        let (_dir, runtime) = sql_test_runtime_configured(None, Some(1));
11585        let runtime = Arc::new(runtime);
11586        runtime
11587            .execute_sql(SqlCommand {
11588                request_id: "overload-schema".into(),
11589                statements: vec![SqlStatement {
11590                    sql: "CREATE TABLE overload_items(id INTEGER PRIMARY KEY)".into(),
11591                    parameters: vec![],
11592                }],
11593            })
11594            .unwrap();
11595        let command = |request_id: &str, id| SqlCommand {
11596            request_id: request_id.into(),
11597            statements: vec![SqlStatement {
11598                sql: "INSERT INTO overload_items(id) VALUES (?1)".into(),
11599                parameters: vec![SqlValue::Integer(id)],
11600            }],
11601        };
11602
11603        let commit = runtime.lock_commit().unwrap();
11604        let leader_runtime = Arc::clone(&runtime);
11605        let leader = std::thread::spawn(move || {
11606            leader_runtime.execute_sql_batch(vec![command("overload-first", 1)])
11607        });
11608        runtime
11609            .sql_group_commit
11610            .wait_for_pending_calls(1, Duration::from_secs(5));
11611        let overload = runtime
11612            .execute_sql_batch(vec![command("overload-second", 2)])
11613            .unwrap_err();
11614        assert!(matches!(overload, NodeError::ResourceExhausted(_)));
11615        drop(commit);
11616        assert!(leader.join().unwrap().unwrap()[0].is_ok());
11617        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11618    }
11619
11620    #[test]
11621    fn sql_group_commit_bounds_pending_bytes_and_releases_reservations() {
11622        let queue = super::SqlGroupCommitQueue::new(super::MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY);
11623        let cancelled = AtomicBool::new(false);
11624        let member = |id: usize| super::RuntimeBatchMember {
11625            request_id: format!("queued-{id}"),
11626            payload: vec![u8::try_from(id).unwrap_or_default(); MAX_COMMAND_BYTES],
11627            operation: super::QueuedOperation::Sql(SqlCommand {
11628                request_id: format!("queued-{id}"),
11629                statements: vec![SqlStatement {
11630                    sql: "SELECT 1".into(),
11631                    parameters: vec![],
11632                }],
11633            }),
11634        };
11635        let mut queued = Vec::new();
11636        for id in 0..super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY {
11637            queued.push(queue.enqueue(vec![member(id)], &cancelled).unwrap().0);
11638        }
11639
11640        let overflow = match queue.enqueue(
11641            vec![member(super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY)],
11642            &cancelled,
11643        ) {
11644            Ok(_) => panic!("pending byte budget must reject one more full command"),
11645            Err(error) => error,
11646        };
11647        assert!(matches!(overflow, NodeError::ResourceExhausted(_)));
11648
11649        let drained = queue.drain_next_group().unwrap();
11650        assert_eq!(drained.len(), 4);
11651        let released = queue
11652            .enqueue(
11653                vec![member(super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY + 1)],
11654                &cancelled,
11655            )
11656            .unwrap()
11657            .0;
11658
11659        queue.fail_pending(NodeError::Unavailable("test failure".into()));
11660        for job in queued.into_iter().skip(drained.len()) {
11661            assert!(matches!(
11662                job.wait(&cancelled),
11663                Err(NodeError::Unavailable(_))
11664            ));
11665        }
11666        assert!(matches!(
11667            released.wait(&cancelled),
11668            Err(NodeError::Unavailable(_))
11669        ));
11670        let state = queue
11671            .state
11672            .lock()
11673            .unwrap_or_else(std::sync::PoisonError::into_inner);
11674        assert!(state.pending.is_empty());
11675        assert_eq!(state.pending_encoded_bytes, 0);
11676        assert!(!state.leader_active);
11677    }
11678
11679    #[test]
11680    fn sql_group_commit_window_restarts_after_staggered_enqueue() {
11681        let queue = Arc::new(super::SqlGroupCommitQueue::new(
11682            super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY,
11683        ));
11684        let cancelled = AtomicBool::new(false);
11685        let member = |id: usize| super::RuntimeBatchMember {
11686            request_id: format!("sql-debounce-{id}"),
11687            payload: vec![u8::try_from(id).unwrap_or_default()],
11688            operation: super::QueuedOperation::Sql(SqlCommand {
11689                request_id: format!("sql-debounce-{id}"),
11690                statements: vec![SqlStatement {
11691                    sql: "SELECT 1".into(),
11692                    parameters: vec![],
11693                }],
11694            }),
11695        };
11696        queue.enqueue(vec![member(1)], &cancelled).unwrap();
11697        let collector = Arc::clone(&queue);
11698        let (finished, receive) = std::sync::mpsc::channel();
11699        let worker = std::thread::spawn(move || {
11700            let collected = collector.collect_until_full_or_timeout(Duration::from_millis(100));
11701            finished.send(collected).unwrap();
11702        });
11703
11704        std::thread::sleep(Duration::from_millis(75));
11705        queue.enqueue(vec![member(2)], &cancelled).unwrap();
11706        std::thread::sleep(Duration::from_millis(75));
11707        queue.enqueue(vec![member(3)], &cancelled).unwrap();
11708        assert!(receive.recv_timeout(Duration::from_millis(50)).is_err());
11709        assert!(receive.recv_timeout(Duration::from_millis(150)).unwrap());
11710        worker.join().unwrap();
11711        assert_eq!(queue.drain_next_group().unwrap().len(), 3);
11712    }
11713
11714    #[test]
11715    fn sql_group_commit_leader_panic_wakes_every_queued_call_with_the_same_fatal_error() {
11716        let (_dir, mut runtime) = sql_test_runtime();
11717        runtime
11718            .execute_sql(SqlCommand {
11719                request_id: "panic-schema".into(),
11720                statements: vec![SqlStatement {
11721                    sql: "CREATE TABLE panic_items(id INTEGER PRIMARY KEY)".into(),
11722                    parameters: vec![],
11723                }],
11724            })
11725            .unwrap();
11726        runtime.sql_group_commit_before_execute_hook =
11727            Some(Arc::new(|| panic!("injected SQL group leader panic")));
11728        let runtime = Arc::new(runtime);
11729        let commit = runtime.lock_commit().unwrap();
11730        let mut workers = Vec::new();
11731        for id in 1..=2 {
11732            let worker_runtime = Arc::clone(&runtime);
11733            workers.push(std::thread::spawn(move || {
11734                worker_runtime.execute_sql_batch(vec![SqlCommand {
11735                    request_id: format!("panic-{id}"),
11736                    statements: vec![SqlStatement {
11737                        sql: "INSERT INTO panic_items(id) VALUES (?1)".into(),
11738                        parameters: vec![SqlValue::Integer(id)],
11739                    }],
11740                }])
11741            }));
11742            runtime
11743                .sql_group_commit
11744                .wait_for_pending_calls(id as usize, Duration::from_secs(5));
11745        }
11746        drop(commit);
11747
11748        let errors = workers
11749            .into_iter()
11750            .map(|worker| worker.join().unwrap().unwrap_err())
11751            .collect::<Vec<_>>();
11752        assert!(errors
11753            .iter()
11754            .all(|error| matches!(error, NodeError::Fatal(_))));
11755        assert_eq!(errors[0].to_string(), errors[1].to_string());
11756        assert!(runtime.is_fatal());
11757    }
11758
11759    #[test]
11760    fn sql_group_commit_shutdown_wakes_queued_calls_with_the_same_unavailable_error() {
11761        let (_dir, runtime) = sql_test_runtime();
11762        let runtime = Arc::new(runtime);
11763        runtime
11764            .execute_sql(SqlCommand {
11765                request_id: "shutdown-schema".into(),
11766                statements: vec![SqlStatement {
11767                    sql: "CREATE TABLE shutdown_items(id INTEGER PRIMARY KEY)".into(),
11768                    parameters: vec![],
11769                }],
11770            })
11771            .unwrap();
11772        let command = |id| SqlCommand {
11773            request_id: format!("shutdown-{id}"),
11774            statements: vec![SqlStatement {
11775                sql: "INSERT INTO shutdown_items(id) VALUES (?1)".into(),
11776                parameters: vec![SqlValue::Integer(id)],
11777            }],
11778        };
11779
11780        let commit = runtime.lock_commit().unwrap();
11781        let leader_runtime = Arc::clone(&runtime);
11782        let leader = std::thread::spawn(move || leader_runtime.execute_sql_batch(vec![command(1)]));
11783        runtime
11784            .sql_group_commit
11785            .wait_for_pending_calls(1, Duration::from_secs(5));
11786        let follower_runtime = Arc::clone(&runtime);
11787        let follower =
11788            std::thread::spawn(move || follower_runtime.execute_sql_batch(vec![command(2)]));
11789        runtime
11790            .sql_group_commit
11791            .wait_for_pending_calls(2, Duration::from_secs(5));
11792
11793        runtime.cancel_operations();
11794        let follower_error = follower.join().unwrap().unwrap_err();
11795        drop(commit);
11796        let leader_error = leader.join().unwrap().unwrap_err();
11797
11798        assert!(matches!(leader_error, NodeError::Unavailable(_)));
11799        assert_eq!(leader_error.to_string(), follower_error.to_string());
11800        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11801    }
11802
11803    #[test]
11804    fn sql_group_commit_reprepares_combined_calls_after_a_foreign_slot_winner() {
11805        let (_dir, runtime) = sql_test_runtime();
11806        let runtime = Arc::new(runtime);
11807        let schema = runtime
11808            .execute_sql(SqlCommand {
11809                request_id: "group-winner-schema".into(),
11810                statements: vec![SqlStatement {
11811                    sql: "CREATE TABLE group_winner(id INTEGER PRIMARY KEY)".into(),
11812                    parameters: vec![],
11813                }],
11814            })
11815            .unwrap();
11816        let winner = runtime
11817            .consensus()
11818            .propose_at(
11819                2,
11820                schema.hash,
11821                Command::new(CommandKind::ReadBarrier, Vec::new()),
11822            )
11823            .unwrap();
11824
11825        let commit = runtime.lock_commit().unwrap();
11826        let mut workers = Vec::new();
11827        for id in 1..=2 {
11828            let worker_runtime = Arc::clone(&runtime);
11829            workers.push(std::thread::spawn(move || {
11830                worker_runtime
11831                    .execute_sql_batch(vec![SqlCommand {
11832                        request_id: format!("group-winner-{id}"),
11833                        statements: vec![SqlStatement {
11834                            sql: "INSERT INTO group_winner(id) VALUES (?1)".into(),
11835                            parameters: vec![SqlValue::Integer(id)],
11836                        }],
11837                    }])
11838                    .unwrap()
11839            }));
11840            runtime
11841                .sql_group_commit
11842                .wait_for_pending_calls(id as usize, Duration::from_secs(5));
11843        }
11844        drop(commit);
11845
11846        let results = workers
11847            .into_iter()
11848            .flat_map(|worker| worker.join().unwrap())
11849            .collect::<Vec<_>>();
11850        assert_eq!(runtime.log_store().read(2).unwrap(), Some(winner));
11851        assert!(results
11852            .iter()
11853            .all(|result| result.as_ref().unwrap().applied_index == 3));
11854        assert_eq!(
11855            results[0].as_ref().unwrap().hash,
11856            results[1].as_ref().unwrap().hash
11857        );
11858    }
11859
11860    #[test]
11861    fn sql_group_commit_all_failed_calls_return_aligned_without_consensus() {
11862        let (_dir, runtime) = sql_test_runtime();
11863        let runtime = Arc::new(runtime);
11864        runtime
11865            .execute_sql(SqlCommand {
11866                request_id: "all-failed-schema".into(),
11867                statements: vec![SqlStatement {
11868                    sql: "CREATE TABLE all_failed(value TEXT UNIQUE)".into(),
11869                    parameters: vec![],
11870                }],
11871            })
11872            .unwrap();
11873        runtime
11874            .execute_sql(SqlCommand {
11875                request_id: "all-failed-existing".into(),
11876                statements: vec![SqlStatement {
11877                    sql: "INSERT INTO all_failed(value) VALUES ('existing')".into(),
11878                    parameters: vec![],
11879                }],
11880            })
11881            .unwrap();
11882
11883        let commit = runtime.lock_commit().unwrap();
11884        let mut workers = Vec::new();
11885        for id in 1..=2 {
11886            let worker_runtime = Arc::clone(&runtime);
11887            workers.push(std::thread::spawn(move || {
11888                worker_runtime
11889                    .execute_sql_batch(vec![SqlCommand {
11890                        request_id: format!("all-failed-{id}"),
11891                        statements: vec![SqlStatement {
11892                            sql: "INSERT INTO all_failed(value) VALUES ('existing')".into(),
11893                            parameters: vec![],
11894                        }],
11895                    }])
11896                    .unwrap()
11897            }));
11898            runtime
11899                .sql_group_commit
11900                .wait_for_pending_calls(id as usize, Duration::from_secs(5));
11901        }
11902        drop(commit);
11903
11904        for worker in workers {
11905            let results = worker.join().unwrap();
11906            assert_eq!(results.len(), 1);
11907            assert!(matches!(
11908                results[0],
11909                Err(NodeError::InvalidSqlStatement { .. })
11910            ));
11911        }
11912        assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11913    }
11914
11915    #[test]
11916    fn sql_group_commit_lone_call_completes_after_the_bounded_collection_round() {
11917        let (_dir, runtime) = sql_test_runtime();
11918
11919        let result = runtime
11920            .execute_sql_batch(vec![SqlCommand {
11921                request_id: "lone-call".into(),
11922                statements: vec![SqlStatement {
11923                    sql: "CREATE TABLE lone_call(id INTEGER PRIMARY KEY)".into(),
11924                    parameters: vec![],
11925                }],
11926            }])
11927            .unwrap();
11928
11929        assert!(result[0].is_ok());
11930        let queue = runtime
11931            .sql_group_commit
11932            .state
11933            .lock()
11934            .unwrap_or_else(std::sync::PoisonError::into_inner);
11935        assert!(queue.pending.is_empty());
11936        assert!(!queue.leader_active);
11937    }
11938
11939    #[test]
11940    fn legacy_put_endpoint_commits_qwal_instead_of_raw_put_payload() {
11941        let (_dir, runtime) = sql_test_runtime();
11942
11943        let response = runtime.write("legacy-put", "key", "value").unwrap();
11944
11945        let entry = runtime
11946            .log_store()
11947            .read(response.applied_index)
11948            .unwrap()
11949            .unwrap();
11950        assert!(entry.payload.starts_with(QWAL_V3_MAGIC));
11951        assert!(!entry.payload.starts_with(b"put\t"));
11952        assert_eq!(
11953            runtime.read("key", ReadConsistency::Local).unwrap().value,
11954            Some("value".into())
11955        );
11956    }
11957
11958    #[test]
11959    fn sql_batch_preserves_order_commits_one_qwal_effect_and_retries_exactly() {
11960        let (_dir, runtime) = sql_test_runtime();
11961        runtime
11962            .execute_sql(SqlCommand {
11963                request_id: "schema".into(),
11964                statements: vec![SqlStatement {
11965                    sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
11966                        .into(),
11967                    parameters: vec![],
11968                }],
11969            })
11970            .unwrap();
11971        let commands = (1..=3)
11972            .map(|id| SqlCommand {
11973                request_id: format!("insert-{id}"),
11974                statements: vec![SqlStatement {
11975                    sql: "INSERT INTO batch_items(id, value) VALUES (?1, ?2)".into(),
11976                    parameters: vec![SqlValue::Integer(id), SqlValue::Text(format!("value-{id}"))],
11977                }],
11978            })
11979            .collect::<Vec<_>>();
11980
11981        let first = runtime.execute_sql_batch(commands.clone()).unwrap();
11982        let first_indices = first
11983            .iter()
11984            .map(|result| result.as_ref().unwrap().applied_index)
11985            .collect::<Vec<_>>();
11986        let log_index = runtime.log_store().last_index().unwrap();
11987        let replay = runtime.execute_sql_batch(commands).unwrap();
11988
11989        assert_eq!(first_indices, vec![2, 2, 2]);
11990        assert_eq!(
11991            first
11992                .iter()
11993                .map(|result| result.as_ref().unwrap().hash)
11994                .collect::<std::collections::HashSet<_>>()
11995                .len(),
11996            1
11997        );
11998        for index in 1..=2 {
11999            let entry = runtime.log_store().read(index).unwrap().unwrap();
12000            assert!(entry.payload.starts_with(QWAL_V3_MAGIC));
12001        }
12002        assert_eq!(
12003            replay
12004                .iter()
12005                .map(|result| result.as_ref().unwrap().applied_index)
12006                .collect::<Vec<_>>(),
12007            first_indices
12008        );
12009        assert_eq!(runtime.log_store().last_index().unwrap(), log_index);
12010    }
12011
12012    #[test]
12013    fn sql_effect_over_qlog_limit_is_resource_exhausted() {
12014        let (_dir, runtime) = sql_test_runtime();
12015        runtime
12016            .execute_sql(SqlCommand {
12017                request_id: "schema".into(),
12018                statements: vec![SqlStatement {
12019                    sql: "CREATE TABLE large_effect(value BLOB NOT NULL)".into(),
12020                    parameters: vec![],
12021                }],
12022            })
12023            .unwrap();
12024
12025        let error = runtime
12026            .execute_sql(SqlCommand {
12027                request_id: "large-effect".into(),
12028                statements: vec![SqlStatement {
12029                    sql: "INSERT INTO large_effect(value) VALUES (randomblob(700000))".into(),
12030                    parameters: vec![],
12031                }],
12032            })
12033            .unwrap_err();
12034
12035        assert!(matches!(error, NodeError::ResourceExhausted(_)));
12036        assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
12037    }
12038
12039    #[test]
12040    fn sql_batch_isolates_request_conflict_from_unrelated_member() {
12041        let (_dir, runtime) = sql_test_runtime();
12042        runtime
12043            .execute_sql(SqlCommand {
12044                request_id: "schema".into(),
12045                statements: vec![SqlStatement {
12046                    sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
12047                        .into(),
12048                    parameters: vec![],
12049                }],
12050            })
12051            .unwrap();
12052        let insert = |request_id: &str, id: i64| SqlCommand {
12053            request_id: request_id.into(),
12054            statements: vec![SqlStatement {
12055                sql: "INSERT INTO batch_items(id, value) VALUES (?1, ?2)".into(),
12056                parameters: vec![SqlValue::Integer(id), SqlValue::Text(format!("value-{id}"))],
12057            }],
12058        };
12059
12060        let results = runtime
12061            .execute_sql_batch(vec![
12062                insert("same", 1),
12063                insert("same", 2),
12064                insert("other", 3),
12065            ])
12066            .unwrap();
12067
12068        assert!(results[0].is_ok());
12069        assert!(matches!(results[1], Err(NodeError::RequestConflict(_))));
12070        let conflict = results[1].as_ref().unwrap_err().classification();
12071        assert_eq!(conflict.code(), "request_conflict");
12072        assert_eq!(conflict.category(), ErrorCategory::Conflict);
12073        assert!(!conflict.retryable());
12074        assert!(results[2].is_ok());
12075        assert_eq!(
12076            results[0].as_ref().unwrap().applied_index,
12077            results[2].as_ref().unwrap().applied_index
12078        );
12079        assert!(runtime.is_ready());
12080    }
12081
12082    #[cfg(feature = "graph")]
12083    #[test]
12084    fn typed_batch_wrong_profile_is_rejected_before_log_attempt() {
12085        let (_dir, runtime) = graph_test_runtime();
12086        let command = SqlCommand {
12087            request_id: "wrong-profile".into(),
12088            statements: vec![SqlStatement {
12089                sql: "CREATE TABLE should_not_exist(id INTEGER PRIMARY KEY)".into(),
12090                parameters: vec![],
12091            }],
12092        };
12093
12094        let error = runtime.execute_sql_batch(vec![command]).unwrap_err();
12095
12096        assert!(matches!(
12097            error,
12098            NodeError::ExecutionProfileMismatch {
12099                expected: ExecutionProfile::Sqlite,
12100                actual: ExecutionProfile::Graph
12101            }
12102        ));
12103        assert_eq!(runtime.log_store().last_index().unwrap(), None);
12104    }
12105
12106    #[cfg(feature = "graph")]
12107    #[test]
12108    fn graph_query_timeout_returns_503_without_latching_readiness() {
12109        let (_dir, runtime) = graph_test_runtime();
12110        let graph = runtime.graph_materializer().unwrap();
12111        let graph_error = graph
12112            .query_read_only(
12113                "UNWIND range(1, 10000) AS x UNWIND range(1, 10000) AS y RETURN sum(x * y) AS total LIMIT 1",
12114                &std::collections::BTreeMap::new(),
12115                1,
12116                1024 * 1024,
12117                1,
12118            )
12119            .unwrap_err();
12120
12121        let response = node_error_response(runtime.map_graph_read_error(graph_error));
12122
12123        assert_eq!(
12124            response.status(),
12125            axum::http::StatusCode::SERVICE_UNAVAILABLE
12126        );
12127        assert!(runtime.is_ready());
12128        assert!(!runtime.is_fatal());
12129    }
12130
12131    #[cfg(feature = "graph")]
12132    #[test]
12133    fn graph_internal_error_returns_500_and_latches_readiness() {
12134        let (_dir, runtime) = graph_test_runtime();
12135
12136        let error =
12137            runtime.map_graph_read_error(rhiza_graph::Error::Ladybug("connection failed".into()));
12138        let response = node_error_response(error);
12139
12140        assert_eq!(
12141            response.status(),
12142            axum::http::StatusCode::INTERNAL_SERVER_ERROR
12143        );
12144        assert!(!runtime.is_ready());
12145        assert!(runtime.is_fatal());
12146    }
12147
12148    fn sql_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12149        sql_test_runtime_configured(None, None)
12150    }
12151
12152    fn sql_test_runtime_with_profiler(
12153        profiler: Option<SqlWriteProfiler>,
12154    ) -> (tempfile::TempDir, NodeRuntime) {
12155        sql_test_runtime_configured(profiler, None)
12156    }
12157
12158    fn sql_test_runtime_configured(
12159        profiler: Option<SqlWriteProfiler>,
12160        queue_capacity: Option<usize>,
12161    ) -> (tempfile::TempDir, NodeRuntime) {
12162        let dir = tempfile::tempdir().unwrap();
12163        let cluster_id = "node-unit-test";
12164        let mut config = NodeConfig::new_embedded(
12165            cluster_id,
12166            "n1",
12167            dir.path().join("node"),
12168            1,
12169            1,
12170            ["n1", "n2", "n3"],
12171        )
12172        .unwrap()
12173        .with_execution_profile(ExecutionProfile::Sqlite)
12174        .unwrap();
12175        if let Some(profiler) = profiler {
12176            config = config.with_sql_write_profiler(profiler);
12177        }
12178        if let Some(queue_capacity) = queue_capacity {
12179            config = config
12180                .with_sql_group_commit_queue_capacity(queue_capacity)
12181                .unwrap();
12182        }
12183        let consensus = Arc::new(
12184            ThreeNodeConsensus::from_recovered_tip(
12185                "rhiza:sql:node-unit-test",
12186                "n1",
12187                1,
12188                1,
12189                [
12190                    dir.path().join("recorders/n1"),
12191                    dir.path().join("recorders/n2"),
12192                    dir.path().join("recorders/n3"),
12193                ],
12194                1,
12195                LogHash::ZERO,
12196            )
12197            .unwrap(),
12198        );
12199        let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12200        (dir, runtime)
12201    }
12202
12203    #[cfg(feature = "graph")]
12204    fn graph_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12205        let dir = tempfile::tempdir().unwrap();
12206        let cluster_id = "node-unit-test";
12207        let config = NodeConfig::new_embedded(
12208            cluster_id,
12209            "n1",
12210            dir.path().join("node"),
12211            1,
12212            1,
12213            ["n1", "n2", "n3"],
12214        )
12215        .unwrap()
12216        .with_execution_profile(ExecutionProfile::Graph)
12217        .unwrap();
12218        let consensus = Arc::new(
12219            ThreeNodeConsensus::from_recovered_tip(
12220                "rhiza:graph:node-unit-test",
12221                "n1",
12222                1,
12223                1,
12224                [
12225                    dir.path().join("recorders/n1"),
12226                    dir.path().join("recorders/n2"),
12227                    dir.path().join("recorders/n3"),
12228                ],
12229                1,
12230                LogHash::ZERO,
12231            )
12232            .unwrap(),
12233        );
12234        let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12235        (dir, runtime)
12236    }
12237
12238    #[cfg(feature = "kv")]
12239    fn kv_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12240        let dir = tempfile::tempdir().unwrap();
12241        let cluster_id = "node-unit-test";
12242        let config = NodeConfig::new_embedded(
12243            cluster_id,
12244            "n1",
12245            dir.path().join("node"),
12246            1,
12247            1,
12248            ["n1", "n2", "n3"],
12249        )
12250        .unwrap()
12251        .with_execution_profile(ExecutionProfile::Kv)
12252        .unwrap();
12253        let consensus = Arc::new(
12254            ThreeNodeConsensus::from_recovered_tip(
12255                "rhiza:kv:node-unit-test",
12256                "n1",
12257                1,
12258                1,
12259                [
12260                    dir.path().join("recorders/n1"),
12261                    dir.path().join("recorders/n2"),
12262                    dir.path().join("recorders/n3"),
12263                ],
12264                1,
12265                LogHash::ZERO,
12266            )
12267            .unwrap(),
12268        );
12269        let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12270        (dir, runtime)
12271    }
12272}
12273
12274#[cfg(feature = "kv")]
12275fn largest_fitting_kv_batch_prefix(commands: &[KvCommandV1]) -> Option<(usize, Vec<u8>)> {
12276    if commands.len() < 3 {
12277        return None;
12278    }
12279    let mut lower = 2_usize;
12280    let mut upper = commands.len() - 1;
12281    let mut largest = None;
12282    while lower <= upper {
12283        let count = lower + (upper - lower) / 2;
12284        let payload = encode_replicated_kv_batch(&commands[..count])
12285            .expect("the validated KV batch prefix remains valid");
12286        if payload.len() <= MAX_COMMAND_BYTES {
12287            largest = Some((count, payload));
12288            lower = count + 1;
12289        } else {
12290            upper = count - 1;
12291        }
12292    }
12293    largest
12294}
12295
12296#[cfg(feature = "graph")]
12297fn validate_typed_batch_len(len: usize) -> Result<(), NodeError> {
12298    if (1..=MAX_WRITE_BATCH_MEMBERS).contains(&len) {
12299        Ok(())
12300    } else {
12301        Err(NodeError::InvalidRequest(format!(
12302            "write batch must contain 1..={MAX_WRITE_BATCH_MEMBERS} commands"
12303        )))
12304    }
12305}
12306
12307#[cfg(feature = "kv")]
12308fn validate_kv_batch_len(len: usize) -> Result<(), NodeError> {
12309    if (1..=MAX_KV_BATCH_MEMBERS).contains(&len) {
12310        Ok(())
12311    } else {
12312        Err(NodeError::InvalidRequest(format!(
12313            "KV write batch must contain 1..={MAX_KV_BATCH_MEMBERS} commands"
12314        )))
12315    }
12316}
12317
12318#[cfg(feature = "sql")]
12319fn validate_sql_batch_len(len: usize) -> Result<(), NodeError> {
12320    if (1..=MAX_TYPED_SQL_WRITE_BATCH_MEMBERS).contains(&len) {
12321        Ok(())
12322    } else {
12323        Err(NodeError::InvalidRequest(format!(
12324            "SQL write batch must contain 1..={MAX_TYPED_SQL_WRITE_BATCH_MEMBERS} commands"
12325        )))
12326    }
12327}
12328
12329fn validate_command_size(payload: &[u8]) -> Result<(), NodeError> {
12330    if payload.len() <= MAX_COMMAND_BYTES {
12331        Ok(())
12332    } else {
12333        Err(NodeError::InvalidRequest(format!(
12334            "command exceeds {MAX_COMMAND_BYTES} bytes"
12335        )))
12336    }
12337}
12338
12339#[cfg(feature = "sql")]
12340fn canonical_put(request_id: &str, key: &str, value: &str) -> Result<Vec<u8>, NodeError> {
12341    validate_field("request_id", request_id, MAX_REQUEST_ID_BYTES, false)?;
12342    validate_key(key)?;
12343    validate_field("value", value, MAX_VALUE_BYTES, true)?;
12344    let payload = encode_put_request(request_id, key, value)
12345        .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
12346    validate_command_size(&payload)?;
12347    Ok(payload)
12348}
12349
12350#[cfg(feature = "sql")]
12351fn encode_sql_command_with_index(command: &SqlCommand) -> Result<Vec<u8>, NodeError> {
12352    encode_sql_command(command).map_err(|error| {
12353        let message = error.to_string();
12354        match first_invalid_sql_statement(command, |prefix| encode_sql_command(prefix).is_err()) {
12355            Some(statement_index) => NodeError::InvalidSqlStatement {
12356                statement_index,
12357                message,
12358            },
12359            None => NodeError::InvalidRequest(message),
12360        }
12361    })
12362}
12363
12364#[cfg(feature = "sql")]
12365fn first_invalid_sql_statement(
12366    command: &SqlCommand,
12367    mut invalid: impl FnMut(&SqlCommand) -> bool,
12368) -> Option<usize> {
12369    if command.statements.is_empty() || command.statements.len() > MAX_SQL_STATEMENTS {
12370        return None;
12371    }
12372    (0..command.statements.len()).find(|statement_index| {
12373        let prefix = SqlCommand {
12374            request_id: command.request_id.clone(),
12375            statements: command.statements[..=*statement_index].to_vec(),
12376        };
12377        invalid(&prefix)
12378    })
12379}
12380
12381#[cfg(feature = "sql")]
12382fn validate_key(key: &str) -> Result<(), NodeError> {
12383    validate_field("key", key, MAX_KEY_BYTES, false)
12384}
12385
12386#[cfg(feature = "sql")]
12387fn validate_field(
12388    name: &str,
12389    value: &str,
12390    max_bytes: usize,
12391    allow_empty: bool,
12392) -> Result<(), NodeError> {
12393    if !allow_empty && value.is_empty() {
12394        return Err(NodeError::InvalidRequest(format!(
12395            "{name} must not be empty"
12396        )));
12397    }
12398    if value.len() > max_bytes {
12399        return Err(NodeError::InvalidRequest(format!(
12400            "{name} exceeds {max_bytes} bytes"
12401        )));
12402    }
12403    if value.contains('\t') {
12404        return Err(NodeError::InvalidRequest(format!(
12405            "{name} must not contain a tab"
12406        )));
12407    }
12408    Ok(())
12409}
12410
12411#[cfg(feature = "sql")]
12412fn write_response(outcome: RequestOutcome) -> WriteResponse {
12413    WriteResponse {
12414        applied_index: outcome.original_log_index(),
12415        hash: outcome.original_log_hash(),
12416    }
12417}
12418
12419fn reconcile_local_storage(
12420    config: &NodeConfig,
12421    log_store: &FileLogStore,
12422    materializer: &Materializer,
12423) -> Result<(), NodeError> {
12424    let mut log_state = log_store
12425        .logical_state()
12426        .map_err(|error| NodeError::Storage(error.to_string()))?;
12427    let mut log_last_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
12428    let applied_index = materializer
12429        .applied_index()
12430        .map_err(|error| NodeError::Storage(error.to_string()))?;
12431    let applied_hash = materializer
12432        .applied_hash()
12433        .map_err(|error| NodeError::Storage(error.to_string()))?;
12434    let mut materializer_configuration = materializer
12435        .configuration_state()
12436        .map_err(|error| NodeError::Storage(error.to_string()))?;
12437
12438    if let Some(anchor) = &log_state.anchor {
12439        if anchor.recovery_generation() != config.recovery_generation {
12440            return Err(NodeError::Reconciliation(format!(
12441                "qlog anchor recovery generation {} differs from runtime generation {}",
12442                anchor.recovery_generation(),
12443                config.recovery_generation
12444            )));
12445        }
12446        if applied_index < anchor.compacted().index() {
12447            return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())));
12448        }
12449    }
12450    if applied_index > log_last_index {
12451        let entries: Option<Vec<LogEntry>> = match materializer {
12452            #[cfg(feature = "sql")]
12453            Materializer::Sql(sql) => Some(
12454                sql.embedded_log_entries(log_last_index.saturating_add(1), applied_index)
12455                    .map_err(|error| NodeError::Reconciliation(error.to_string()))?,
12456            ),
12457            #[cfg(feature = "kv")]
12458            Materializer::Kv(kv) => Some(
12459                kv.embedded_log_entries(log_last_index.saturating_add(1), applied_index)
12460                    .map_err(|error| NodeError::Reconciliation(error.to_string()))?,
12461            ),
12462            #[cfg(feature = "graph")]
12463            Materializer::Graph(_) => None,
12464            #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
12465            Materializer::Unavailable => None,
12466        };
12467        if let Some(entries) = entries {
12468            log_store
12469                .append_batch(&entries)
12470                .map_err(|error| NodeError::Storage(error.to_string()))?;
12471            log_state = log_store
12472                .logical_state()
12473                .map_err(|error| NodeError::Storage(error.to_string()))?;
12474            log_last_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
12475        }
12476    }
12477    if applied_index > log_last_index {
12478        return Err(NodeError::Reconciliation(format!(
12479            "{} materializer is ahead at {applied_index}, qlog ends at {log_last_index}",
12480            materializer.profile()
12481        )));
12482    }
12483    if applied_index == 0 {
12484        if applied_hash != LogHash::ZERO {
12485            return Err(NodeError::Reconciliation(format!(
12486                "{} materializer genesis hash is not zero",
12487                materializer.profile()
12488            )));
12489        }
12490    } else if !log_state.anchor.as_ref().is_some_and(|anchor| {
12491        applied_index == anchor.compacted().index() && applied_hash == anchor.compacted().hash()
12492    }) {
12493        let entry = log_store
12494            .read(applied_index)
12495            .map_err(|error| NodeError::Storage(error.to_string()))?
12496            .ok_or_else(|| {
12497                NodeError::Reconciliation(format!(
12498                    "qlog prefix is missing {} materializer index {applied_index}",
12499                    materializer.profile()
12500                ))
12501            })?;
12502        validate_entry_envelope(config, &entry, applied_index, entry.prev_hash)?;
12503        if entry.hash != applied_hash {
12504            return Err(NodeError::Reconciliation(format!(
12505                "{} materializer hash diverges from qlog at index {applied_index}",
12506                materializer.profile()
12507            )));
12508        }
12509    }
12510
12511    let mut expected_prev_hash = applied_hash;
12512    for index in (applied_index + 1)..=log_last_index {
12513        let entry = log_store
12514            .read(index)
12515            .map_err(|error| NodeError::Storage(error.to_string()))?
12516            .ok_or_else(|| {
12517                NodeError::Reconciliation(format!("qlog prefix is missing index {index}"))
12518            })?;
12519        match &materializer_configuration {
12520            Some(configuration) => {
12521                validate_runtime_entry(config, configuration, &entry, index, expected_prev_hash)?
12522            }
12523            None => validate_entry_envelope(config, &entry, index, expected_prev_hash)?,
12524        };
12525        materializer
12526            .apply_entry(&entry)
12527            .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12528        materializer_configuration = materializer
12529            .configuration_state()
12530            .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12531        expected_prev_hash = entry.hash;
12532    }
12533    let log_configuration = log_store
12534        .configuration_state()
12535        .map_err(|error| NodeError::Storage(error.to_string()))?;
12536    if materializer_configuration
12537        .as_ref()
12538        .is_some_and(|configuration| configuration != &log_configuration)
12539    {
12540        return Err(NodeError::Reconciliation(format!(
12541            "qlog and {} materializer configuration states disagree",
12542            materializer.profile()
12543        )));
12544    }
12545    Ok(())
12546}
12547
12548fn recover_peer_candidates(
12549    config: &NodeConfig,
12550    consensus: &ThreeNodeConsensus,
12551    log_store: &FileLogStore,
12552    materializer: &Materializer,
12553    peer_candidates: &[&dyn LogPeer],
12554) -> Result<(), NodeError> {
12555    for peer in peer_candidates {
12556        let (last_index, last_hash) = static_log_tip(log_store)?;
12557        let candidates = match peer.fetch_log(FetchLogRequest {
12558            from_index: last_index.saturating_add(1),
12559            max_entries: MAX_FETCH_ENTRIES,
12560        }) {
12561            Ok(response) => validate_fetched_entries_with_configuration(
12562                last_index.saturating_add(1),
12563                last_hash,
12564                &config.cluster_id,
12565                config.epoch,
12566                log_store
12567                    .configuration_state()
12568                    .map_err(|error| NodeError::Storage(error.to_string()))?,
12569                response.entries,
12570            )
12571            .map_err(|error| {
12572                NodeError::Reconciliation(format!("peer candidate validation failed: {error}"))
12573            })?,
12574            Err(FetchLogError::Transport { .. }) => continue,
12575            Err(FetchLogError::SnapshotRequired { anchor }) => {
12576                return Err(NodeError::SnapshotRequired(anchor));
12577            }
12578            Err(error) => {
12579                return Err(NodeError::Reconciliation(format!(
12580                    "peer candidate validation failed: {error}"
12581                )));
12582            }
12583        };
12584
12585        let mut expected_index = last_index.checked_add(1).ok_or_else(|| {
12586            NodeError::Reconciliation("qlog index is exhausted during peer catch-up".into())
12587        })?;
12588        let mut expected_prev_hash = last_hash;
12589        for candidate in candidates {
12590            match consensus
12591                .inspect_decision_at(expected_index, expected_prev_hash)
12592                .map_err(startup_consensus_error)?
12593            {
12594                DecisionInspection::Committed(committed) if committed == candidate => {
12595                    persist_startup_entry(
12596                        config,
12597                        log_store,
12598                        materializer,
12599                        &candidate,
12600                        expected_index,
12601                        expected_prev_hash,
12602                    )?;
12603                    expected_prev_hash = candidate.hash;
12604                    expected_index = expected_index.checked_add(1).ok_or_else(|| {
12605                        NodeError::Reconciliation(
12606                            "qlog index is exhausted during peer catch-up".into(),
12607                        )
12608                    })?;
12609                }
12610                DecisionInspection::Committed(_) => {
12611                    return Err(NodeError::Reconciliation(format!(
12612                        "peer candidate at index {expected_index} differs from committed decision"
12613                    )));
12614                }
12615                DecisionInspection::Unavailable => {
12616                    return Err(NodeError::Unavailable(format!(
12617                        "decision inspection unavailable for peer candidate at index {expected_index}"
12618                    )));
12619                }
12620                DecisionInspection::Empty | DecisionInspection::Pending => {
12621                    return Err(NodeError::Reconciliation(format!(
12622                        "peer candidate at index {expected_index} is not committed"
12623                    )));
12624                }
12625            }
12626        }
12627    }
12628    Ok(())
12629}
12630
12631fn recover_startup_decisions(
12632    config: &NodeConfig,
12633    consensus: &ThreeNodeConsensus,
12634    log_store: &FileLogStore,
12635    materializer: &Materializer,
12636) -> Result<(), NodeError> {
12637    for _ in 0..MAX_STARTUP_RECOVERY_ENTRIES {
12638        let (last_index, last_hash) = static_log_tip(log_store)?;
12639        let slot = last_index.checked_add(1).ok_or_else(|| {
12640            NodeError::Reconciliation("qlog index is exhausted during startup".into())
12641        })?;
12642        match consensus
12643            .inspect_decision_at(slot, last_hash)
12644            .map_err(startup_consensus_error)?
12645        {
12646            DecisionInspection::Committed(entry) => {
12647                persist_startup_entry(config, log_store, materializer, &entry, slot, last_hash)?;
12648            }
12649            DecisionInspection::Pending => {
12650                let entry = consensus
12651                    .propose_at(
12652                        slot,
12653                        last_hash,
12654                        Command::new(CommandKind::ReadBarrier, Vec::new()),
12655                    )
12656                    .map_err(startup_consensus_error)?;
12657                persist_startup_entry(config, log_store, materializer, &entry, slot, last_hash)?;
12658            }
12659            DecisionInspection::Empty => return Ok(()),
12660            DecisionInspection::Unavailable => {
12661                return Err(NodeError::Unavailable(
12662                    "decision inspection unavailable during startup".into(),
12663                ));
12664            }
12665        }
12666    }
12667    Err(NodeError::Reconciliation(format!(
12668        "startup recovery exceeded {MAX_STARTUP_RECOVERY_ENTRIES} entries"
12669    )))
12670}
12671
12672fn persist_startup_entry(
12673    config: &NodeConfig,
12674    log_store: &FileLogStore,
12675    materializer: &Materializer,
12676    entry: &LogEntry,
12677    expected_index: LogIndex,
12678    expected_prev_hash: LogHash,
12679) -> Result<(), NodeError> {
12680    let configuration_state = log_store
12681        .configuration_state()
12682        .map_err(|error| NodeError::Storage(error.to_string()))?;
12683    validate_runtime_entry(
12684        config,
12685        &configuration_state,
12686        entry,
12687        expected_index,
12688        expected_prev_hash,
12689    )?;
12690    log_store
12691        .append(entry)
12692        .map_err(|error| NodeError::Storage(error.to_string()))?;
12693    materializer
12694        .apply_entry(entry)
12695        .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12696    Ok(())
12697}
12698
12699fn static_log_tip(log_store: &FileLogStore) -> Result<(LogIndex, LogHash), NodeError> {
12700    Ok(log_store
12701        .logical_state()
12702        .map_err(|error| NodeError::Storage(error.to_string()))?
12703        .tip
12704        .map_or((0, LogHash::ZERO), |tip| (tip.index(), tip.hash())))
12705}
12706
12707fn validate_runtime_entry(
12708    config: &NodeConfig,
12709    configuration_state: &ConfigurationState,
12710    entry: &LogEntry,
12711    expected_index: LogIndex,
12712    expected_prev_hash: LogHash,
12713) -> Result<(), NodeError> {
12714    validate_entry_envelope(config, entry, expected_index, expected_prev_hash)?;
12715    validate_profile_entry_shape(config.execution_profile(), entry)
12716        .map_err(NodeError::Invariant)?;
12717    configuration_state
12718        .validate_entry(entry)
12719        .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12720    Ok(())
12721}
12722
12723fn validate_entry_envelope(
12724    config: &NodeConfig,
12725    entry: &LogEntry,
12726    expected_index: LogIndex,
12727    expected_prev_hash: LogHash,
12728) -> Result<(), NodeError> {
12729    if entry.index != expected_index {
12730        return Err(NodeError::Reconciliation(format!(
12731            "expected decision index {expected_index}, got {}",
12732            entry.index
12733        )));
12734    }
12735    if entry.cluster_id != config.cluster_id || entry.epoch != config.epoch {
12736        return Err(NodeError::Reconciliation(format!(
12737            "decision {} has a foreign identity",
12738            entry.index
12739        )));
12740    }
12741    if entry.prev_hash != expected_prev_hash {
12742        return Err(NodeError::Reconciliation(format!(
12743            "decision {} has a conflicting predecessor",
12744            entry.index
12745        )));
12746    }
12747    if entry.recompute_hash() != entry.hash {
12748        return Err(NodeError::Reconciliation(format!(
12749            "decision {} has an invalid hash",
12750            entry.index
12751        )));
12752    }
12753    validate_entry_shape(entry).map_err(NodeError::Invariant)
12754}
12755
12756fn validate_entry_shape(entry: &LogEntry) -> Result<(), String> {
12757    match entry.entry_type {
12758        EntryType::Command if entry.payload.len() <= MAX_COMMAND_BYTES => Ok(()),
12759        EntryType::Command => Err(format!("command exceeds {MAX_COMMAND_BYTES} bytes")),
12760        EntryType::Noop if entry.payload.is_empty() => Ok(()),
12761        EntryType::Noop => Err("Noop payload must be empty".into()),
12762        EntryType::ConfigChange => ConfigChange::recognize(&StoredCommand::new(
12763            EntryType::ConfigChange,
12764            entry.payload.clone(),
12765        ))
12766        .map(|_| ())
12767        .map_err(|error| error.to_string()),
12768        other => Err(format!("unsupported runtime entry type {other:?}")),
12769    }
12770}
12771
12772pub(crate) fn validate_profile_entry_shape(
12773    _profile: ExecutionProfile,
12774    entry: &LogEntry,
12775) -> Result<(), String> {
12776    validate_entry_shape(entry)?;
12777    #[cfg(feature = "sql")]
12778    if _profile == ExecutionProfile::Sqlite && entry.entry_type == EntryType::Command {
12779        decode_qwal_v3(&entry.payload)
12780            .map_err(|error| format!("SQLite command is not canonical QWAL v3: {error}"))?;
12781    }
12782    Ok(())
12783}
12784
12785fn startup_consensus_error(error: rhiza_quepaxa::Error) -> NodeError {
12786    match error {
12787        rhiza_quepaxa::Error::NoQuorum
12788        | rhiza_quepaxa::Error::CommandUnavailable
12789        | rhiza_quepaxa::Error::Io(_) => NodeError::Unavailable(error.to_string()),
12790        other => NodeError::Reconciliation(other.to_string()),
12791    }
12792}
12793
12794#[cfg(feature = "sql")]
12795#[derive(Clone, Debug, Eq, PartialEq)]
12796pub struct E2eConfig {
12797    pub data_dir: PathBuf,
12798    pub object_store: ObjStoreConfig,
12799    pub cluster_id: String,
12800    pub node_id: String,
12801}
12802
12803#[cfg(feature = "sql")]
12804#[derive(Clone, Debug, Eq, PartialEq)]
12805pub struct E2eReport {
12806    pub applied_index: LogIndex,
12807    pub restored_value: String,
12808    pub object_keys: Vec<String>,
12809}
12810
12811#[cfg(feature = "sql")]
12812pub async fn run_e2e(config: E2eConfig) -> Result<E2eReport, Box<dyn std::error::Error>> {
12813    let sqlite_dir = config.data_dir.join("sqlite");
12814    let log_dir = config.data_dir.join("consensus").join("log");
12815    ensure_fresh_e2e_data_dir(&config.data_dir, &sqlite_dir, &log_dir)?;
12816
12817    fs::create_dir_all(&config.data_dir)?;
12818    let db_path = sqlite_dir.join("db.sqlite");
12819    let restore_path = config.data_dir.join("restore").join("db.sqlite");
12820    let db = SqliteStateMachine::open(&db_path, &config.cluster_id, &config.node_id, 1, 1)?;
12821    let recorder_dir = config.data_dir.join("consensus").join("recorder");
12822    let consensus = ThreeNodeConsensus::new(
12823        &config.cluster_id,
12824        &config.node_id,
12825        1,
12826        1,
12827        [
12828            recorder_dir.join("node-1"),
12829            recorder_dir.join("node-2"),
12830            recorder_dir.join("node-3"),
12831        ],
12832    )?;
12833    let base_request = canonical_put("e2e-base", "alpha", "bravo")?;
12834    let base_effect = db.prepare_put_effect(
12835        "e2e-base",
12836        "alpha",
12837        "bravo",
12838        &base_request,
12839        0,
12840        LogHash::ZERO,
12841    )?;
12842    let base_entry = consensus.propose(Command::new(CommandKind::Deterministic, base_effect))?;
12843    db.apply_entry(&base_entry)?;
12844    let snapshot = db.create_snapshot(base_entry.index)?;
12845
12846    let tail_request = canonical_put("e2e-tail", "alpha", "charlie")?;
12847    let tail_effect = db.prepare_put_effect(
12848        "e2e-tail",
12849        "alpha",
12850        "charlie",
12851        &tail_request,
12852        base_entry.index,
12853        base_entry.hash,
12854    )?;
12855    let tail_entry = consensus.propose(Command::new(CommandKind::Deterministic, tail_effect))?;
12856    let segment_path = write_segment_file(&log_dir, std::slice::from_ref(&tail_entry))?;
12857    let segment = rhiza_log::SegmentFile::new(
12858        IndexRange::new(tail_entry.index, tail_entry.index)?,
12859        fs::read(&segment_path)?,
12860    );
12861    db.apply_entry(&tail_entry)?;
12862
12863    let local_archive = matches!(&config.object_store, ObjStoreConfig::Local { .. });
12864    let store = ObjStore::new(config.object_store)?;
12865    let archive = if local_archive {
12866        rhiza_archive::ObjectArchiveStore::new_for_single_process(
12867            store.clone(),
12868            config.cluster_id.clone(),
12869        )
12870    } else {
12871        rhiza_archive::ObjectArchiveStore::new(store.clone(), config.cluster_id.clone())?
12872    };
12873
12874    let segment_record = archive.publish_segment(tail_entry.epoch, &segment).await?;
12875    let snapshot_record = archive.publish_snapshot(&snapshot).await?;
12876    let (mut archive_manifest, expected_manifest_version) = match archive.load_manifest().await? {
12877        Some(loaded) => (loaded.manifest().clone(), Some(loaded.version().clone())),
12878        None => (
12879            rhiza_archive::ArchiveManifest::new(config.cluster_id.clone()),
12880            None,
12881        ),
12882    };
12883    archive_manifest.set_latest_snapshot(snapshot_record);
12884    archive_manifest.add_segment(segment_record);
12885    archive
12886        .publish_manifest(&archive_manifest, expected_manifest_version)
12887        .await?;
12888
12889    let loaded_manifest = archive
12890        .load_manifest()
12891        .await?
12892        .ok_or("published archive manifest is missing")?;
12893    if loaded_manifest.manifest() != &archive_manifest {
12894        return Err("reloaded archive manifest did not match the published manifest".into());
12895    }
12896    let archived_snapshot = loaded_manifest
12897        .manifest()
12898        .latest_snapshot()
12899        .ok_or("archive manifest is missing its snapshot")?;
12900    let archived_segment = loaded_manifest
12901        .manifest()
12902        .segments()
12903        .iter()
12904        .find(|record| {
12905            record.start_index() == tail_entry.index && record.end_index() == tail_entry.index
12906        })
12907        .ok_or("archive manifest is missing its post-snapshot segment")?;
12908
12909    let downloaded_segment = archive.download_segment(archived_segment).await?;
12910    let downloaded_entries = decode_segment_for_cluster(&downloaded_segment, &config.cluster_id)?;
12911    if downloaded_entries.as_slice() != std::slice::from_ref(&tail_entry) {
12912        return Err("downloaded qlog segment did not match written entry".into());
12913    }
12914    let downloaded_snapshot = rhiza_core::Snapshot::new(
12915        archived_snapshot.manifest().clone(),
12916        archive.download_snapshot(archived_snapshot).await?,
12917    );
12918    restore_snapshot_file(&restore_path, &downloaded_snapshot, &config.node_id)?;
12919    let restored_db = SqliteStateMachine::open_existing(&restore_path)?;
12920    if restored_db.get_value("alpha")?.as_deref() != Some("bravo") {
12921        return Err("restored base snapshot is missing alpha=bravo".into());
12922    }
12923    for entry in &downloaded_entries {
12924        restored_db.apply_entry(entry)?;
12925    }
12926    let restored_value = restored_db
12927        .get_value("alpha")?
12928        .ok_or("restored SQLite state is missing alpha")?;
12929    let applied_index = restored_db.applied_index_value()?;
12930    if applied_index != tail_entry.index || restored_value != "charlie" {
12931        return Err("restored SQLite state did not include the archived log tail".into());
12932    }
12933    let object_keys = store.list(&format!("rhiza/{}", config.cluster_id)).await?;
12934
12935    Ok(E2eReport {
12936        applied_index,
12937        restored_value,
12938        object_keys,
12939    })
12940}
12941
12942#[cfg(feature = "sql")]
12943fn ensure_fresh_e2e_data_dir(
12944    data_dir: &std::path::Path,
12945    sqlite_dir: &std::path::Path,
12946    log_dir: &std::path::Path,
12947) -> Result<(), Box<dyn std::error::Error>> {
12948    if directory_has_entries(sqlite_dir)? || directory_has_entries(log_dir)? {
12949        return Err(format!(
12950            "e2e data directory is not fresh: prior SQLite/qlog data exists in {}",
12951            data_dir.display()
12952        )
12953        .into());
12954    }
12955    Ok(())
12956}
12957
12958#[cfg(feature = "sql")]
12959fn directory_has_entries(path: &std::path::Path) -> Result<bool, std::io::Error> {
12960    match fs::read_dir(path) {
12961        Ok(mut entries) => entries.next().transpose().map(|entry| entry.is_some()),
12962        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
12963        Err(error) => Err(error),
12964    }
12965}