1#![cfg_attr(
2 not(any(feature = "sql", feature = "graph", feature = "kv")),
3 allow(dead_code, unreachable_code, unused_imports, unused_variables)
4)]
5
6use std::{
7 collections::{HashMap, HashSet},
8 fmt, fs,
9 path::{Path, PathBuf},
10 sync::{
11 atomic::{AtomicBool, Ordering},
12 Arc, Condvar, Mutex, MutexGuard, OnceLock,
13 },
14 time::{Duration, Instant},
15};
16
17#[cfg(any(feature = "sql", feature = "kv"))]
18use std::collections::VecDeque;
19
20#[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
21compile_error!("rhiza-node requires at least one execution profile feature: sql, graph, or kv");
22
23#[cfg(feature = "graph")]
24use std::collections::BTreeMap;
25#[cfg(any(feature = "sql", feature = "kv", test))]
26use std::sync::atomic::AtomicUsize;
27
28use axum::{
29 extract::{rejection::JsonRejection, DefaultBodyLimit, Extension, Request, State},
30 http::{HeaderMap, StatusCode},
31 middleware::{self, Next},
32 response::{IntoResponse, Response},
33 routing::{get, post},
34 Json, Router,
35};
36#[cfg(feature = "sql")]
37use rhiza_archive::SnapshotRecord;
38use rhiza_core::{
39 Command, CommandKind, ConfigChange, ConfigurationState, EntryType, ErrorClassification,
40 ExecutionProfile, LogAnchor, LogEntry, LogHash, LogIndex, RecoveryAnchor, StoredCommand,
41};
42#[cfg(feature = "graph")]
43use rhiza_graph::{
44 encode_replicated_graph_batch, encode_replicated_graph_command, CanonicalF64, GraphColumn,
45 GraphInternalId, GraphLogicalType, GraphNode, GraphParameterValue, GraphQueryResult, GraphRel,
46 GraphResultValue, LadybugStateMachine, RequestRecord as GraphRequestRecord,
47};
48#[cfg(feature = "kv")]
49use rhiza_kv::{
50 encode_replicated_kv_batch, encode_replicated_kv_command, KvRequestRecord, RedbStateMachine,
51 MAX_KV_BATCH_MEMBERS,
52};
53#[cfg(feature = "kv")]
54pub use rhiza_kv::{KvScanResult, KvScanRow, MAX_KV_SCAN_RESULT_BYTES, MAX_KV_SCAN_ROWS};
55#[cfg(feature = "sql")]
56use rhiza_log::{decode_segment_for_cluster, write_segment_file};
57use rhiza_log::{FileLogStore, IndexRange, LogStore};
58#[cfg(feature = "sql")]
59use rhiza_obj_store::{ObjStore, ObjStoreConfig};
60#[cfg(feature = "sql")]
61use rhiza_quepaxa::Consensus;
62use rhiza_quepaxa::{
63 CertifiedDecisionInspection, DecisionInspection, DecisionProof, Membership,
64 ReadFenceObservation, ReadFenceRequest, RecordRequest, RecordSummary, RecorderFileStore,
65 RecorderRpc, RejectReason, ThreeNodeConsensus,
66};
67#[cfg(feature = "sql")]
68use rhiza_sql::{
69 decode_qwal_v3, encode_put_request, encode_sql_command, restore_snapshot_file,
70 RecoverySnapshot, RequestConflict, RequestOutcome, SqlBatchMember, SqlCommand,
71 SqlCommandResult, SqlQueryResult, SqlStatement, SqlValue, SqliteStateMachine,
72 MAX_QWAL_V3_RECEIPTS, MAX_SQL_STATEMENTS, QWAL_V3_MAGIC,
73};
74#[cfg(not(feature = "sql"))]
75type SqlCommandResult = ();
76
77mod admin;
78pub mod durability;
79#[cfg(feature = "graph")]
80mod graph;
81#[cfg(feature = "kv")]
82mod kv;
83mod learner;
84mod recorder_tcp;
85pub use admin::*;
86pub use durability::{
87 restore_checkpoint_to_fresh_data_dir, restore_checkpoint_to_fresh_data_dir_for_node,
88 CheckpointCoordinator, DurabilityError, DurabilityHealth, DurabilityMode,
89 SuccessorRestorePreparation,
90};
91#[cfg(feature = "graph")]
92pub use graph::*;
93#[cfg(feature = "kv")]
94pub use kv::*;
95pub use learner::*;
96#[cfg(feature = "recorder-postcard-rpc")]
97pub use recorder_tcp::{
98 serve_recorder_postcard_rpc, serve_recorder_postcard_rpc_tls,
99 RecorderPostcardRpcTlsClientConfig, RecorderPostcardRpcTlsServerConfig,
100 TcpPostcardRpcRecorderClient,
101};
102pub use recorder_tcp::{
103 serve_recorder_tcp, serve_recorder_tcp_tls, validate_recorder_tcp_endpoint,
104 RecorderTlsClientConfig, RecorderTlsServerConfig, TcpPostcardRecorderClient,
105};
106
107pub const MAX_FETCH_ENTRIES: u32 = 1_024;
108pub const MAX_COMMAND_BYTES: usize = 512 * 1024;
109pub const MAX_REQUEST_ID_BYTES: usize = 256;
110pub const MAX_KEY_BYTES: usize = 4 * 1024;
111pub const MAX_VALUE_BYTES: usize = 240 * 1024;
112pub const MAX_HTTP_BODY_BYTES: usize = MAX_COMMAND_BYTES * 6 + 16 * 1024;
113pub const DEFAULT_CLIENT_CONCURRENCY: usize = 16;
114pub const DEFAULT_PEER_CONCURRENCY: usize = 32;
115pub const DEFAULT_WRITER_BATCH_MAX: usize = 8;
116const MAX_WRITE_BATCH_MEMBERS: usize = 64;
117#[cfg(feature = "sql")]
118const MAX_SQL_WRITE_BATCH_MEMBERS: usize = MAX_QWAL_V3_RECEIPTS;
119#[cfg(feature = "sql")]
120pub const MAX_TYPED_SQL_WRITE_BATCH_MEMBERS: usize = 256;
121#[cfg(feature = "sql")]
122pub const DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY: usize = 64;
123#[cfg(feature = "sql")]
124pub const MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY: usize = 4_096;
125#[cfg(feature = "sql")]
127const MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES: usize = 4 * MAX_COMMAND_BYTES;
128#[cfg(feature = "sql")]
130const MAX_SQL_GROUP_COMMIT_PENDING_BYTES: usize =
131 DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY * MAX_COMMAND_BYTES;
132#[cfg(feature = "kv")]
133const MAX_KV_GROUP_COMMIT_MEMBERS: usize = 1_024;
134#[cfg(feature = "kv")]
135const KV_GROUP_COMMIT_QUEUE_CAPACITY: usize = 64;
136#[cfg(feature = "kv")]
137const MAX_KV_GROUP_COMMIT_PENDING_BYTES: usize = KV_GROUP_COMMIT_QUEUE_CAPACITY * MAX_COMMAND_BYTES;
138pub const DEFAULT_WRITER_BATCH_WINDOW: Duration = Duration::from_micros(500);
139pub const PROTOCOL_VERSION: &str = "1";
140pub const RECORDER_PROTOCOL_VERSION: &str = "3";
141const RECORDER_WIRE_VERSION: u16 = 3;
142pub const VERSION_HEADER: &str = "x-rhiza-version";
143pub const NODE_ID_HEADER: &str = "x-rhiza-node-id";
144pub const RECOVERY_GENERATION_HEADER: &str = "x-rhiza-recovery-generation";
145pub const RECORDER_IDENTITY_PATH: &str = "/v2/quepaxa/recorder/identity";
146
147#[cfg(feature = "sql")]
152#[derive(Clone)]
153pub struct SqlWriteProfiler {
154 inner: Arc<SqlWriteProfilerInner>,
155}
156
157#[cfg(feature = "sql")]
158struct SqlWriteProfilerInner {
159 capacity: usize,
160 state: Mutex<SqlWriteProfilerState>,
161}
162
163#[cfg(feature = "sql")]
164#[derive(Default)]
165struct SqlWriteProfilerState {
166 samples: VecDeque<SqlWriteProfileSample>,
167 dropped_samples: u64,
168}
169
170#[cfg(feature = "sql")]
172#[derive(Clone, Debug, Eq, PartialEq)]
173pub struct SqlWriteProfileSample {
174 pub batch_member_count: usize,
175 pub commit_lock_wait_us: u64,
176 pub precheck_classification_us: u64,
177 pub qwal_prepare_us: u64,
178 pub consensus_propose_us: u64,
179 pub local_qlog_mirror_append_us: u64,
180 pub sql_materializer_apply_us: u64,
181 pub response_other_total_us: u64,
182 pub total_service_us: u64,
183}
184
185#[cfg(feature = "sql")]
187#[derive(Clone, Debug, Default, Eq, PartialEq)]
188pub struct SqlWriteProfileSnapshot {
189 pub samples: Vec<SqlWriteProfileSample>,
190 pub dropped_samples: u64,
191}
192
193#[cfg(feature = "sql")]
194impl SqlWriteProfiler {
195 pub fn new(capacity: usize) -> Self {
201 assert!(capacity > 0, "SQL write profiler capacity must be non-zero");
202 Self {
203 inner: Arc::new(SqlWriteProfilerInner {
204 capacity,
205 state: Mutex::new(SqlWriteProfilerState::default()),
206 }),
207 }
208 }
209
210 pub fn snapshot(&self) -> SqlWriteProfileSnapshot {
211 let state = self
212 .inner
213 .state
214 .lock()
215 .unwrap_or_else(std::sync::PoisonError::into_inner);
216 SqlWriteProfileSnapshot {
217 samples: state.samples.iter().cloned().collect(),
218 dropped_samples: state.dropped_samples,
219 }
220 }
221
222 pub fn drain(&self) -> SqlWriteProfileSnapshot {
224 let mut state = self
225 .inner
226 .state
227 .lock()
228 .unwrap_or_else(std::sync::PoisonError::into_inner);
229 SqlWriteProfileSnapshot {
230 samples: state.samples.drain(..).collect(),
231 dropped_samples: state.dropped_samples,
232 }
233 }
234
235 fn record(&self, sample: SqlWriteProfileSample) {
236 let mut state = self
237 .inner
238 .state
239 .lock()
240 .unwrap_or_else(std::sync::PoisonError::into_inner);
241 if state.samples.len() == self.inner.capacity {
242 state.samples.pop_front();
243 state.dropped_samples = state.dropped_samples.saturating_add(1);
244 }
245 state.samples.push_back(sample);
246 }
247}
248
249#[cfg(feature = "sql")]
250impl fmt::Debug for SqlWriteProfiler {
251 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252 f.debug_struct("SqlWriteProfiler")
253 .field("capacity", &self.inner.capacity)
254 .finish_non_exhaustive()
255 }
256}
257
258#[cfg(feature = "sql")]
259impl PartialEq for SqlWriteProfiler {
260 fn eq(&self, other: &Self) -> bool {
261 Arc::ptr_eq(&self.inner, &other.inner)
262 }
263}
264
265#[cfg(feature = "sql")]
266impl Eq for SqlWriteProfiler {}
267pub const RECORDER_STORE_COMMAND_PATH: &str = "/v2/quepaxa/recorder/store-command";
268pub const RECORDER_FETCH_COMMAND_PATH: &str = "/v2/quepaxa/recorder/fetch-command";
269pub const RECORDER_INSPECT_PROOF_PATH: &str = "/v2/quepaxa/recorder/inspect-proof";
270pub const RECORDER_INSPECT_RECORD_PATH: &str = "/v2/quepaxa/recorder/inspect-record";
271pub const RECORDER_READ_FENCE_PATH: &str = "/v3/quepaxa/recorder/read-fence";
272pub const RECORDER_RECORD_PATH: &str = "/v2/quepaxa/recorder/record";
273pub const RECORDER_INSTALL_PROOF_PATH: &str = "/v2/quepaxa/recorder/install-decision-proof";
274pub const LOG_FETCH_PATH: &str = "/v1/log/fetch";
275#[cfg(feature = "sql")]
276pub const WRITE_PATH: &str = "/v1/write";
277#[cfg(feature = "sql")]
278pub const READ_PATH: &str = "/v1/read";
279#[cfg(feature = "sql")]
280pub const SQL_EXECUTE_PATH: &str = "/v1/sql/execute";
281#[cfg(feature = "sql")]
282pub const SQL_QUERY_PATH: &str = "/v1/sql/query";
283#[cfg(feature = "graph")]
284pub const GRAPH_PUT_DOCUMENT_PATH: &str = "/v1/graph/documents/put";
285#[cfg(feature = "graph")]
286pub const GRAPH_DELETE_DOCUMENT_PATH: &str = "/v1/graph/documents/delete";
287#[cfg(feature = "graph")]
288pub const GRAPH_GET_DOCUMENT_PATH: &str = "/v1/graph/documents/get";
289#[cfg(feature = "graph")]
290pub const GRAPH_QUERY_PATH: &str = "/v1/graph/query";
291#[cfg(feature = "kv")]
292pub const KV_PUT_PATH: &str = "/v1/kv/put";
293#[cfg(feature = "kv")]
294pub const KV_DELETE_PATH: &str = "/v1/kv/delete";
295#[cfg(feature = "kv")]
296pub const KV_GET_PATH: &str = "/v1/kv/get";
297#[cfg(feature = "kv")]
298pub const KV_SCAN_PATH: &str = "/v1/kv/scan";
299pub const LIVEZ_PATH: &str = "/livez";
300pub const READYZ_PATH: &str = "/readyz";
301const MAX_STARTUP_RECOVERY_ENTRIES: usize = 100_000;
302const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
303const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
304const READ_FENCE_REQUEST_TIMEOUT: Duration = Duration::from_secs(1);
305const QUORUM_RECORD_REQUEST_TIMEOUT: Duration = Duration::from_millis(250);
308const CLIENT_WRITE_WAIT_TIMEOUT: Duration = Duration::from_secs(1);
309const SYNC_FLUSH_RETRY_INITIAL: Duration = Duration::from_millis(50);
310const SYNC_FLUSH_RETRY_MAX: Duration = Duration::from_secs(1);
311
312fn map_quorum_record_transport_error(error: rhiza_quepaxa::Error) -> rhiza_quepaxa::Error {
313 match error {
314 rhiza_quepaxa::Error::Io(_) | rhiza_quepaxa::Error::Decode(_) => {
315 rhiza_quepaxa::Error::ProposeFailed
316 }
317 error => error,
318 }
319}
320#[cfg(feature = "sql")]
321pub const DEFAULT_SQL_MAX_ROWS: u32 = 1_000;
322#[cfg(feature = "sql")]
323pub const MAX_SQL_MAX_ROWS: u32 = 10_000;
324#[cfg(feature = "sql")]
325pub const MAX_SQL_RESULT_BYTES: usize = 1024 * 1024;
326#[cfg(feature = "sql")]
327pub const MAX_SQL_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
328#[cfg(feature = "kv")]
329type KvMemberCheck = (usize, Result<Option<KvRequestRecord>, NodeError>);
330#[cfg(feature = "kv")]
331pub const DEFAULT_KV_SCAN_LIMIT: u32 = 100;
332#[cfg(feature = "kv")]
333pub const MAX_KV_SCAN_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
334#[cfg(feature = "graph")]
335pub const DEFAULT_GRAPH_MAX_ROWS: u32 = 1_000;
336#[cfg(feature = "graph")]
337pub const MAX_GRAPH_MAX_ROWS: u32 = 10_000;
338#[cfg(feature = "graph")]
339pub const MAX_GRAPH_RESULT_BYTES: usize = 1024 * 1024;
340#[cfg(feature = "graph")]
341pub const MAX_GRAPH_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
342#[cfg(feature = "graph")]
343const GRAPH_QUERY_TIMEOUT_MS: u64 = 5_000;
344
345pub fn effective_cluster_id(
346 profile: ExecutionProfile,
347 logical_cluster_id: &str,
348) -> Result<String, ConfigError> {
349 if let Some(actual) = canonical_cluster_profile(logical_cluster_id) {
350 if actual != profile {
351 return Err(ConfigError::ClusterIdProfileMismatch {
352 expected: profile,
353 actual,
354 });
355 }
356 return Ok(logical_cluster_id.to_owned());
357 }
358 Ok(format!("rhiza:{}:{logical_cluster_id}", profile.as_str()))
359}
360
361pub const fn execution_profile_compiled(profile: ExecutionProfile) -> bool {
362 match profile {
363 ExecutionProfile::Sqlite => cfg!(feature = "sql"),
364 ExecutionProfile::Graph => cfg!(feature = "graph"),
365 ExecutionProfile::Kv => cfg!(feature = "kv"),
366 }
367}
368
369fn canonical_cluster_profile(cluster_id: &str) -> Option<ExecutionProfile> {
370 [
371 ("rhiza:sql:", ExecutionProfile::Sqlite),
372 ("rhiza:graph:", ExecutionProfile::Graph),
373 ("rhiza:kv:", ExecutionProfile::Kv),
374 ]
375 .into_iter()
376 .find_map(|(prefix, profile)| cluster_id.starts_with(prefix).then_some(profile))
377}
378
379#[derive(Clone, Debug, Eq, PartialEq)]
380pub enum ConfigError {
381 EmptyClusterId,
382 ClusterIdProfileMismatch {
383 expected: ExecutionProfile,
384 actual: ExecutionProfile,
385 },
386 EmptyNodeId,
387 EmptyDataDir,
388 InvalidEpoch,
389 InvalidConfigId,
390 InvalidRecoveryGeneration,
391 InvalidWriterBatchMax(usize),
392 InvalidWriterBatchWindow,
393 #[cfg(feature = "sql")]
394 InvalidSqlGroupCommitQueueCapacity(usize),
395 EmptyPeerNodeId,
396 EmptyPeerBaseUrl,
397 InvalidPeerBaseUrl(String),
398 EmptyPeerToken,
399 DuplicatePeerToken,
400 InvalidPeerCount(usize),
401 DuplicatePeerNodeId(String),
402 LocalNodeMissing,
403 PeerMembershipMismatch,
404 InvalidPredecessorTransition(String),
405 EmptyClientToken,
406 ClientTokenConflictsWithPeer,
407 EmptyAdminToken,
408 AdminTokenConflictsWithRuntime,
409 HttpClient(String),
410}
411
412impl fmt::Display for ConfigError {
413 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
414 match self {
415 Self::EmptyClusterId => write!(f, "cluster_id must not be empty"),
416 Self::ClusterIdProfileMismatch { expected, actual } => write!(
417 f,
418 "cluster_id is canonical for the {} profile, not {}",
419 actual.as_str(),
420 expected.as_str()
421 ),
422 Self::EmptyNodeId => write!(f, "node_id must not be empty"),
423 Self::EmptyDataDir => write!(f, "data_dir must not be empty"),
424 Self::InvalidEpoch => write!(f, "epoch must be positive"),
425 Self::InvalidConfigId => write!(f, "config_id must be positive"),
426 Self::InvalidRecoveryGeneration => {
427 write!(f, "recovery_generation must be positive")
428 }
429 Self::InvalidWriterBatchMax(max) => write!(
430 f,
431 "writer batch max must be within 1..={MAX_WRITE_BATCH_MEMBERS}, got {max}"
432 ),
433 Self::InvalidWriterBatchWindow => write!(
434 f,
435 "writer batch window must be positive and shorter than the client deadline"
436 ),
437 #[cfg(feature = "sql")]
438 Self::InvalidSqlGroupCommitQueueCapacity(capacity) => write!(
439 f,
440 "SQL group commit queue capacity must be within 1..={MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY}, got {capacity}"
441 ),
442 Self::EmptyPeerNodeId => write!(f, "peer node_id must not be empty"),
443 Self::EmptyPeerBaseUrl => write!(f, "peer base_url must not be empty"),
444 Self::InvalidPeerBaseUrl(url) => write!(f, "invalid peer base_url: {url}"),
445 Self::EmptyPeerToken => write!(f, "peer token must not be empty"),
446 Self::DuplicatePeerToken => write!(f, "peer tokens must be unique"),
447 Self::InvalidPeerCount(count) => {
448 write!(
449 f,
450 "peer membership requires between three and seven nodes, got {count}"
451 )
452 }
453 Self::DuplicatePeerNodeId(node_id) => {
454 write!(f, "peer node_id must be unique: {node_id}")
455 }
456 Self::LocalNodeMissing => write!(f, "peer set must include the local node_id"),
457 Self::PeerMembershipMismatch => {
458 write!(
459 f,
460 "peer identities must exactly match the canonical membership"
461 )
462 }
463 Self::InvalidPredecessorTransition(message) => {
464 write!(f, "invalid predecessor transition: {message}")
465 }
466 Self::EmptyClientToken => write!(f, "client token must not be empty"),
467 Self::ClientTokenConflictsWithPeer => {
468 write!(f, "client token must differ from every peer token")
469 }
470 Self::EmptyAdminToken => write!(f, "admin token must not be empty"),
471 Self::AdminTokenConflictsWithRuntime => {
472 write!(f, "admin token must differ from client and peer tokens")
473 }
474 Self::HttpClient(message) => write!(f, "HTTP client configuration failed: {message}"),
475 }
476 }
477}
478
479impl std::error::Error for ConfigError {}
480
481fn validate_recovery_generation(recovery_generation: u64) -> Result<(), ConfigError> {
482 if recovery_generation == 0 {
483 Err(ConfigError::InvalidRecoveryGeneration)
484 } else {
485 Ok(())
486 }
487}
488
489#[derive(Clone, Copy, Debug, Eq, PartialEq)]
490pub enum AckMode {
491 HaFirst,
492 DrStrong,
493}
494
495#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
496#[serde(rename_all = "snake_case")]
497pub enum ReadConsistency {
498 Local,
499 ReadBarrier,
500 AppliedIndex(LogIndex),
501}
502
503#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
504pub struct FetchLogRequest {
505 pub from_index: LogIndex,
506 pub max_entries: u32,
507}
508
509#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
510pub struct FetchLogResponse {
511 pub entries: Vec<LogEntry>,
512 pub last_index: LogIndex,
513}
514
515#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
516pub enum FetchLogError {
517 SnapshotRequired {
518 anchor: Box<RecoveryAnchor>,
519 },
520 Gap {
521 expected: LogIndex,
522 actual: Option<LogIndex>,
523 },
524 Decode {
525 message: String,
526 },
527 Transport {
528 message: String,
529 },
530 InvalidAnchor {
531 expected: LogHash,
532 actual: LogHash,
533 },
534 InvalidEntry {
535 index: LogIndex,
536 message: String,
537 },
538 ForeignIdentity {
539 index: LogIndex,
540 },
541 InvalidRequest {
542 message: String,
543 },
544}
545
546impl fmt::Display for FetchLogError {
547 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
548 match self {
549 Self::SnapshotRequired { anchor } => {
550 write!(
551 f,
552 "snapshot restore required at qlog anchor {}",
553 anchor.compacted().index()
554 )
555 }
556 Self::Gap { expected, actual } => {
557 write!(f, "qlog gap: expected {expected}, got {actual:?}")
558 }
559 Self::Decode { message } => write!(f, "qlog response decode failed: {message}"),
560 Self::Transport { message } => write!(f, "qlog transport failed: {message}"),
561 Self::InvalidAnchor { .. } => write!(f, "qlog response has an invalid anchor"),
562 Self::InvalidEntry { index, message } => {
563 write!(f, "qlog entry {index} is invalid: {message}")
564 }
565 Self::ForeignIdentity { index } => {
566 write!(f, "qlog entry {index} has a foreign identity")
567 }
568 Self::InvalidRequest { message } => write!(f, "invalid qlog request: {message}"),
569 }
570 }
571}
572
573impl std::error::Error for FetchLogError {}
574
575pub trait LogPeer: Send + Sync {
576 fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError>;
582}
583
584#[derive(Clone, Debug)]
585pub struct StartupIoContext {
586 cancelled: Arc<AtomicBool>,
587 deadline: Arc<OnceLock<Instant>>,
588 stage: Arc<Mutex<&'static str>>,
589 mutation_admission: Arc<Mutex<()>>,
590}
591
592impl Default for StartupIoContext {
593 fn default() -> Self {
594 Self::new()
595 }
596}
597
598impl StartupIoContext {
599 pub fn new() -> Self {
600 Self {
601 cancelled: Arc::new(AtomicBool::new(false)),
602 deadline: Arc::new(OnceLock::new()),
603 stage: Arc::new(Mutex::new("runtime initialization")),
604 mutation_admission: Arc::new(Mutex::new(())),
605 }
606 }
607
608 pub fn cancel(&self, deadline: Instant) {
609 self.cancelled.store(true, Ordering::Release);
610 let _ = self.deadline.set(deadline);
611 }
612
613 pub fn unfinished_stage(&self) -> &'static str {
614 *self.lock_stage()
615 }
616
617 pub fn check(&self, stage: &'static str) -> Result<(), NodeError> {
618 *self.lock_stage() = stage;
619 if self.cancelled.load(Ordering::Acquire)
620 || self
621 .deadline
622 .get()
623 .is_some_and(|deadline| Instant::now() >= *deadline)
624 {
625 return Err(NodeError::Unavailable(format!(
626 "startup cancelled during {stage}"
627 )));
628 }
629 Ok(())
630 }
631
632 fn persist<T>(
633 &self,
634 stage: &'static str,
635 operation: impl FnOnce() -> Result<T, NodeError>,
636 ) -> Result<T, NodeError> {
637 *self.lock_stage() = stage;
638 let _permit = self
639 .mutation_admission
640 .lock()
641 .unwrap_or_else(std::sync::PoisonError::into_inner);
642 if self.cancelled.load(Ordering::Acquire)
643 || self
644 .deadline
645 .get()
646 .is_some_and(|deadline| Instant::now() >= *deadline)
647 {
648 return Err(NodeError::Unavailable(format!(
649 "startup cancelled before {stage}"
650 )));
651 }
652 operation()
653 }
654
655 fn cancellation_flag(&self) -> &AtomicBool {
656 self.cancelled.as_ref()
657 }
658
659 fn lock_stage(&self) -> std::sync::MutexGuard<'_, &'static str> {
660 self.stage
661 .lock()
662 .unwrap_or_else(std::sync::PoisonError::into_inner)
663 }
664}
665
666#[derive(Clone, Debug, Eq, PartialEq)]
667pub struct InMemoryLogPeer {
668 entries: Vec<LogEntry>,
669 anchor: Option<RecoveryAnchor>,
670}
671
672impl InMemoryLogPeer {
673 pub fn new(mut entries: Vec<LogEntry>) -> Self {
674 entries.sort_by_key(|entry| entry.index);
675 Self {
676 entries,
677 anchor: None,
678 }
679 }
680
681 pub fn with_anchor(mut entries: Vec<LogEntry>, anchor: RecoveryAnchor) -> Self {
682 entries.sort_by_key(|entry| entry.index);
683 Self {
684 entries,
685 anchor: Some(anchor),
686 }
687 }
688}
689
690impl LogPeer for InMemoryLogPeer {
691 fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
692 if let Some(anchor) = &self.anchor {
693 if request.from_index <= anchor.compacted().index() {
694 return Err(FetchLogError::SnapshotRequired {
695 anchor: Box::new(anchor.clone()),
696 });
697 }
698 }
699 let entries = self
700 .entries
701 .iter()
702 .filter(|entry| entry.index >= request.from_index)
703 .take(request.max_entries as usize)
704 .cloned()
705 .collect();
706 let last_index = self
707 .entries
708 .last()
709 .map(|entry| entry.index)
710 .or_else(|| {
711 self.anchor
712 .as_ref()
713 .map(|anchor| anchor.compacted().index())
714 })
715 .unwrap_or(0);
716 Ok(FetchLogResponse {
717 entries,
718 last_index,
719 })
720 }
721}
722
723#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
724struct RecorderWire<T> {
725 version: u16,
726 body: T,
727}
728
729#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
730#[serde(tag = "status", content = "body")]
731enum RecorderV2Result<T> {
732 Ok(T),
733 Rejected(RejectReason),
734 Error(String),
735}
736
737#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
738struct StoreCommandV2 {
739 cluster_id: String,
740 epoch: u64,
741 config_id: u64,
742 config_digest: LogHash,
743 command_hash: LogHash,
744 command: StoredCommand,
745}
746
747#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
748struct FetchCommandV2 {
749 cluster_id: String,
750 epoch: u64,
751 config_id: u64,
752 config_digest: LogHash,
753 command_hash: LogHash,
754}
755
756#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
757struct InspectProofV2 {
758 slot: u64,
759}
760
761#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
762struct InstallProofV2 {
763 proof: DecisionProof,
764 members: Vec<String>,
765}
766
767#[derive(Clone, Debug)]
768pub struct HttpRecorderClient {
769 base_url: String,
770 local_node_id: String,
771 peer_token: String,
772 recovery_generation: u64,
773 client: Arc<std::sync::OnceLock<reqwest::blocking::Client>>,
774}
775
776impl HttpRecorderClient {
777 pub fn new(
778 base_url: impl Into<String>,
779 local_node_id: impl Into<String>,
780 peer_token: impl Into<String>,
781 ) -> Result<Self, ConfigError> {
782 Self::new_with_recovery_generation(base_url, local_node_id, peer_token, 1)
783 }
784
785 pub fn new_with_recovery_generation(
786 base_url: impl Into<String>,
787 local_node_id: impl Into<String>,
788 peer_token: impl Into<String>,
789 recovery_generation: u64,
790 ) -> Result<Self, ConfigError> {
791 validate_recovery_generation(recovery_generation)?;
792 let peer = PeerConfig::new(local_node_id, base_url, peer_token)?;
793 Ok(Self {
794 base_url: peer.base_url,
795 local_node_id: peer.node_id,
796 peer_token: peer.token,
797 recovery_generation,
798 client: Arc::new(std::sync::OnceLock::new()),
799 })
800 }
801
802 pub fn with_recovery_generation(
803 mut self,
804 recovery_generation: u64,
805 ) -> Result<Self, ConfigError> {
806 validate_recovery_generation(recovery_generation)?;
807 self.recovery_generation = recovery_generation;
808 Ok(self)
809 }
810
811 fn url(&self, path: &str) -> String {
812 format!("{}{}", self.base_url, path)
813 }
814
815 fn client(&self) -> rhiza_quepaxa::Result<&reqwest::blocking::Client> {
816 if self.client.get().is_none() {
817 let client = reqwest::blocking::Client::builder()
818 .connect_timeout(HTTP_CONNECT_TIMEOUT)
819 .timeout(HTTP_REQUEST_TIMEOUT)
820 .build()
821 .map_err(|error| rhiza_quepaxa::Error::Io(error.to_string()))?;
822 let _ = self.client.set(client);
823 }
824 self.client
825 .get()
826 .ok_or_else(|| rhiza_quepaxa::Error::Io("HTTP client initialization failed".into()))
827 }
828
829 fn post_v2<T, U>(&self, path: &str, body: T) -> rhiza_quepaxa::Result<U>
830 where
831 T: serde::Serialize,
832 U: serde::de::DeserializeOwned,
833 {
834 self.post_v2_with_timeout(path, body, HTTP_REQUEST_TIMEOUT)
835 }
836
837 fn post_v2_with_timeout<T, U>(
838 &self,
839 path: &str,
840 body: T,
841 timeout: Duration,
842 ) -> rhiza_quepaxa::Result<U>
843 where
844 T: serde::Serialize,
845 U: serde::de::DeserializeOwned,
846 {
847 let response = self
848 .client()?
849 .post(self.url(path))
850 .timeout(timeout)
851 .header(VERSION_HEADER, RECORDER_PROTOCOL_VERSION)
852 .header(NODE_ID_HEADER, &self.local_node_id)
853 .header(
854 RECOVERY_GENERATION_HEADER,
855 self.recovery_generation.to_string(),
856 )
857 .bearer_auth(&self.peer_token)
858 .json(&RecorderWire {
859 version: RECORDER_WIRE_VERSION,
860 body,
861 })
862 .send()
863 .map_err(|error| rhiza_quepaxa::Error::Io(error.to_string()))?;
864 let status = response.status();
865 let wire = response
866 .json::<RecorderWire<RecorderV2Result<U>>>()
867 .map_err(|error| rhiza_quepaxa::Error::Decode(error.to_string()))?;
868 if wire.version != RECORDER_WIRE_VERSION {
869 return Err(rhiza_quepaxa::Error::Decode(
870 "recorder wire version mismatch".into(),
871 ));
872 }
873 match wire.body {
874 RecorderV2Result::Ok(value) if status.is_success() => Ok(value),
875 RecorderV2Result::Ok(_) => Err(rhiza_quepaxa::Error::Io(format!(
876 "recorder rpc returned HTTP {status}"
877 ))),
878 RecorderV2Result::Rejected(reason) => Err(rhiza_quepaxa::Error::Rejected(reason)),
879 RecorderV2Result::Error(message) => Err(rhiza_quepaxa::Error::Io(message)),
880 }
881 }
882}
883
884impl RecorderRpc for HttpRecorderClient {
885 fn recorder_id(&self) -> rhiza_quepaxa::Result<String> {
886 self.post_v2(RECORDER_IDENTITY_PATH, ())
887 }
888
889 fn store_command_for(
890 &self,
891 cluster_id: String,
892 epoch: u64,
893 config_id: u64,
894 config_digest: LogHash,
895 command_hash: LogHash,
896 command: StoredCommand,
897 ) -> rhiza_quepaxa::Result<()> {
898 self.post_v2(
899 RECORDER_STORE_COMMAND_PATH,
900 StoreCommandV2 {
901 cluster_id,
902 epoch,
903 config_id,
904 config_digest,
905 command_hash,
906 command,
907 },
908 )
909 }
910
911 fn fetch_command_for(
912 &self,
913 cluster_id: String,
914 epoch: u64,
915 config_id: u64,
916 config_digest: LogHash,
917 command_hash: LogHash,
918 ) -> rhiza_quepaxa::Result<Option<StoredCommand>> {
919 self.post_v2(
920 RECORDER_FETCH_COMMAND_PATH,
921 FetchCommandV2 {
922 cluster_id,
923 epoch,
924 config_id,
925 config_digest,
926 command_hash,
927 },
928 )
929 }
930
931 fn record(&self, request: RecordRequest) -> rhiza_quepaxa::Result<RecordSummary> {
932 self.post_v2_with_timeout(RECORDER_RECORD_PATH, request, QUORUM_RECORD_REQUEST_TIMEOUT)
933 .map_err(map_quorum_record_transport_error)
934 }
935
936 fn install_decision_proof(
937 &self,
938 proof: DecisionProof,
939 membership: &Membership,
940 ) -> rhiza_quepaxa::Result<()> {
941 self.post_v2(
942 RECORDER_INSTALL_PROOF_PATH,
943 InstallProofV2 {
944 proof,
945 members: membership.members().to_vec(),
946 },
947 )
948 }
949
950 fn inspect_decision_proof(&self, slot: u64) -> rhiza_quepaxa::Result<Option<DecisionProof>> {
951 self.post_v2(RECORDER_INSPECT_PROOF_PATH, InspectProofV2 { slot })
952 }
953
954 fn inspect_record_summary(&self, slot: u64) -> rhiza_quepaxa::Result<Option<RecordSummary>> {
955 self.post_v2(RECORDER_INSPECT_RECORD_PATH, InspectProofV2 { slot })
956 }
957
958 fn supports_context_read_fence(&self) -> bool {
959 true
960 }
961
962 fn observe_read_fence(
963 &self,
964 request: ReadFenceRequest,
965 ) -> rhiza_quepaxa::Result<ReadFenceObservation> {
966 self.post_v2_with_timeout(
967 RECORDER_READ_FENCE_PATH,
968 request,
969 READ_FENCE_REQUEST_TIMEOUT,
970 )
971 }
972}
973
974#[derive(Clone, Debug)]
975pub struct HttpLogPeer {
976 base_url: String,
977 local_node_id: String,
978 peer_token: String,
979 recovery_generation: u64,
980 client: Arc<std::sync::OnceLock<reqwest::blocking::Client>>,
981}
982
983impl HttpLogPeer {
984 pub fn new(
985 base_url: impl Into<String>,
986 local_node_id: impl Into<String>,
987 peer_token: impl Into<String>,
988 ) -> Result<Self, ConfigError> {
989 Self::new_with_recovery_generation(base_url, local_node_id, peer_token, 1)
990 }
991
992 pub fn new_with_recovery_generation(
993 base_url: impl Into<String>,
994 local_node_id: impl Into<String>,
995 peer_token: impl Into<String>,
996 recovery_generation: u64,
997 ) -> Result<Self, ConfigError> {
998 validate_recovery_generation(recovery_generation)?;
999 let peer = PeerConfig::new(local_node_id, base_url, peer_token)?;
1000 Ok(Self {
1001 base_url: peer.base_url,
1002 local_node_id: peer.node_id,
1003 peer_token: peer.token,
1004 recovery_generation,
1005 client: Arc::new(std::sync::OnceLock::new()),
1006 })
1007 }
1008
1009 pub fn with_recovery_generation(
1010 mut self,
1011 recovery_generation: u64,
1012 ) -> Result<Self, ConfigError> {
1013 validate_recovery_generation(recovery_generation)?;
1014 self.recovery_generation = recovery_generation;
1015 Ok(self)
1016 }
1017
1018 fn url(&self, path: &str) -> String {
1019 format!("{}{}", self.base_url, path)
1020 }
1021
1022 fn client(&self) -> Result<&reqwest::blocking::Client, FetchLogError> {
1023 if self.client.get().is_none() {
1024 let client = reqwest::blocking::Client::builder()
1025 .connect_timeout(HTTP_CONNECT_TIMEOUT)
1026 .timeout(HTTP_REQUEST_TIMEOUT)
1027 .build()
1028 .map_err(|error| FetchLogError::Transport {
1029 message: error.to_string(),
1030 })?;
1031 let _ = self.client.set(client);
1032 }
1033 self.client.get().ok_or_else(|| FetchLogError::Transport {
1034 message: "HTTP client initialization failed".into(),
1035 })
1036 }
1037}
1038
1039impl LogPeer for HttpLogPeer {
1040 fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
1041 let response = self
1042 .client()?
1043 .post(self.url(LOG_FETCH_PATH))
1044 .header(VERSION_HEADER, PROTOCOL_VERSION)
1045 .header(NODE_ID_HEADER, &self.local_node_id)
1046 .header(
1047 RECOVERY_GENERATION_HEADER,
1048 self.recovery_generation.to_string(),
1049 )
1050 .bearer_auth(&self.peer_token)
1051 .json(&request)
1052 .send()
1053 .map_err(|err| FetchLogError::Transport {
1054 message: err.to_string(),
1055 })?;
1056 let status = response.status();
1057 if !status.is_success() {
1058 return match response.json::<FetchLogHttpResponse>() {
1059 Ok(FetchLogHttpResponse::Failed(error)) => Err(error),
1060 Ok(FetchLogHttpResponse::Fetched(_)) | Err(_) => Err(FetchLogError::Transport {
1061 message: format!("log rpc returned HTTP {status}"),
1062 }),
1063 };
1064 }
1065 match response
1066 .json::<FetchLogHttpResponse>()
1067 .map_err(|err| FetchLogError::Decode {
1068 message: err.to_string(),
1069 })? {
1070 FetchLogHttpResponse::Fetched(response) => Ok(response),
1071 FetchLogHttpResponse::Failed(error) => Err(error),
1072 }
1073 }
1074}
1075
1076#[derive(Clone)]
1077struct RecorderRouteState<R> {
1078 recorder: R,
1079 peers: Vec<PeerConfig>,
1080}
1081
1082#[derive(Clone)]
1083struct AuthenticatedPeer(String);
1084
1085#[derive(Clone)]
1086struct LogRouteState<P> {
1087 peer: P,
1088}
1089
1090#[derive(Clone)]
1091struct NodeRouteState {
1092 runtime: Arc<NodeRuntime>,
1093 coordinator: Option<Arc<CheckpointCoordinator>>,
1094 write_operations: Arc<tokio::sync::Mutex<HashMap<String, WriteOperation>>>,
1095 writer: tokio::sync::mpsc::Sender<QueuedWrite>,
1096}
1097
1098#[derive(Clone)]
1099struct WriteOperation {
1100 payload: Vec<u8>,
1101 result: tokio::sync::watch::Receiver<Option<WriteOperationResult>>,
1102}
1103
1104#[derive(Clone)]
1105enum WriteOperationResult {
1106 Runtime(Result<ClientWriteResponse, NodeError>),
1107 DurabilityUnavailable,
1108}
1109
1110#[derive(Clone)]
1111enum ClientWriteResponse {
1112 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1113 Unavailable,
1114 #[cfg(feature = "sql")]
1115 KeyValue(WriteResponse),
1116 #[cfg(feature = "sql")]
1117 Sql(SqlExecuteResponse),
1118 #[cfg(feature = "graph")]
1119 Graph(GraphMutationOutcome),
1120 #[cfg(feature = "kv")]
1121 Kv(KvMutationOutcome),
1122}
1123
1124struct QueuedWrite {
1125 request_id: String,
1126 payload: Vec<u8>,
1127 operation: QueuedOperation,
1128 permit: Arc<tokio::sync::OwnedSemaphorePermit>,
1129 sender: tokio::sync::watch::Sender<Option<WriteOperationResult>>,
1130}
1131
1132enum QueuedOperation {
1133 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1134 Unavailable,
1135 #[cfg(feature = "sql")]
1136 KeyValue { key: String, value: String },
1137 #[cfg(feature = "sql")]
1138 Sql(SqlCommand),
1139 #[cfg(feature = "graph")]
1140 Graph(GraphCommandV1),
1141 #[cfg(feature = "kv")]
1142 Kv(KvCommandV1),
1143}
1144
1145struct RuntimeBatchMember {
1146 #[cfg(feature = "sql")]
1147 request_id: String,
1148 payload: Vec<u8>,
1149 operation: QueuedOperation,
1150}
1151
1152#[cfg(feature = "sql")]
1153type SqlGroupCommitResult = Result<Vec<Result<ClientWriteResponse, NodeError>>, NodeError>;
1154
1155#[cfg(feature = "sql")]
1156struct SqlGroupCommitJob {
1157 member_count: usize,
1158 encoded_bytes: usize,
1159 members: Mutex<Option<Vec<RuntimeBatchMember>>>,
1160 result: Mutex<Option<SqlGroupCommitResult>>,
1161 changed: Condvar,
1162}
1163
1164#[cfg(feature = "sql")]
1165impl SqlGroupCommitJob {
1166 fn new(members: Vec<RuntimeBatchMember>) -> Self {
1167 Self {
1168 member_count: members.len(),
1169 encoded_bytes: members.iter().fold(0_usize, |bytes, member| {
1170 bytes.saturating_add(member.payload.len())
1171 }),
1172 members: Mutex::new(Some(members)),
1173 result: Mutex::new(None),
1174 changed: Condvar::new(),
1175 }
1176 }
1177
1178 fn take_members(&self) -> Result<Vec<RuntimeBatchMember>, NodeError> {
1179 self.members
1180 .lock()
1181 .unwrap_or_else(std::sync::PoisonError::into_inner)
1182 .take()
1183 .ok_or_else(|| {
1184 NodeError::Invariant("SQL group commit job members were already consumed".into())
1185 })
1186 }
1187
1188 fn publish(&self, result: SqlGroupCommitResult) {
1189 let mut slot = self
1190 .result
1191 .lock()
1192 .unwrap_or_else(std::sync::PoisonError::into_inner);
1193 if slot.is_none() {
1194 *slot = Some(result);
1195 self.changed.notify_all();
1196 }
1197 }
1198
1199 fn wait(&self, cancelled: &AtomicBool) -> SqlGroupCommitResult {
1200 let mut result = self
1201 .result
1202 .lock()
1203 .unwrap_or_else(std::sync::PoisonError::into_inner);
1204 loop {
1205 if let Some(result) = result.take() {
1206 return result;
1207 }
1208 if cancelled.load(Ordering::Acquire) {
1209 return Err(NodeError::Unavailable(
1210 "SQL group commit cancelled during shutdown".into(),
1211 ));
1212 }
1213 result = self
1214 .changed
1215 .wait(result)
1216 .unwrap_or_else(std::sync::PoisonError::into_inner);
1217 }
1218 }
1219}
1220
1221#[cfg(feature = "sql")]
1222struct SqlGroupCommitQueue {
1223 capacity: usize,
1224 state: Mutex<SqlGroupCommitQueueState>,
1225 changed: Condvar,
1226}
1227
1228#[cfg(feature = "sql")]
1229#[derive(Default)]
1230struct SqlGroupCommitQueueState {
1231 pending: VecDeque<Arc<SqlGroupCommitJob>>,
1232 pending_encoded_bytes: usize,
1233 leader_active: bool,
1234}
1235
1236#[cfg(feature = "sql")]
1237impl SqlGroupCommitQueue {
1238 fn new(capacity: usize) -> Self {
1239 Self {
1240 capacity,
1241 state: Mutex::new(SqlGroupCommitQueueState::default()),
1242 changed: Condvar::new(),
1243 }
1244 }
1245
1246 fn enqueue(
1247 &self,
1248 members: Vec<RuntimeBatchMember>,
1249 cancelled: &AtomicBool,
1250 ) -> Result<(Arc<SqlGroupCommitJob>, bool), NodeError> {
1251 if cancelled.load(Ordering::Acquire) {
1252 return Err(NodeError::Unavailable(
1253 "SQL group commit is unavailable during shutdown".into(),
1254 ));
1255 }
1256 let job = Arc::new(SqlGroupCommitJob::new(members));
1257 if job.encoded_bytes > MAX_COMMAND_BYTES {
1258 return Err(NodeError::ResourceExhausted(format!(
1259 "SQL group commit call exceeds {MAX_COMMAND_BYTES} encoded bytes"
1260 )));
1261 }
1262 let mut state = self
1263 .state
1264 .lock()
1265 .unwrap_or_else(std::sync::PoisonError::into_inner);
1266 if state.pending.len() >= self.capacity {
1267 return Err(NodeError::ResourceExhausted(format!(
1268 "SQL group commit queue is full (capacity {})",
1269 self.capacity
1270 )));
1271 }
1272 let pending_encoded_bytes = state
1273 .pending_encoded_bytes
1274 .saturating_add(job.encoded_bytes);
1275 if pending_encoded_bytes > MAX_SQL_GROUP_COMMIT_PENDING_BYTES {
1276 return Err(NodeError::ResourceExhausted(format!(
1277 "SQL group commit queue exceeds {MAX_SQL_GROUP_COMMIT_PENDING_BYTES} pending encoded bytes"
1278 )));
1279 }
1280 state.pending_encoded_bytes = pending_encoded_bytes;
1281 state.pending.push_back(Arc::clone(&job));
1282 self.changed.notify_all();
1283 let leader = !state.leader_active;
1284 if leader {
1285 state.leader_active = true;
1286 }
1287 Ok((job, leader))
1288 }
1289
1290 fn drain_next_group(&self) -> Option<Vec<Arc<SqlGroupCommitJob>>> {
1291 let mut state = self
1292 .state
1293 .lock()
1294 .unwrap_or_else(std::sync::PoisonError::into_inner);
1295 if state.pending.is_empty() {
1296 state.leader_active = false;
1297 return None;
1298 }
1299 let mut member_count = 0_usize;
1300 let mut encoded_bytes = 0_usize;
1301 let mut jobs = Vec::new();
1302 while let Some(job) = state.pending.front() {
1303 let next_count = member_count.saturating_add(job.member_count);
1304 let next_encoded_bytes = encoded_bytes.saturating_add(job.encoded_bytes);
1305 if !jobs.is_empty()
1306 && (next_count > MAX_SQL_WRITE_BATCH_MEMBERS
1307 || next_encoded_bytes > MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES)
1308 {
1309 break;
1310 }
1311 let job = state.pending.pop_front().expect("front job exists");
1312 member_count = next_count;
1313 encoded_bytes = next_encoded_bytes;
1314 state.pending_encoded_bytes = state
1315 .pending_encoded_bytes
1316 .checked_sub(job.encoded_bytes)
1317 .expect("queued SQL byte reservation covers every pending job");
1318 jobs.push(job);
1319 }
1320 Some(jobs)
1321 }
1322
1323 fn collect_until_full_or_timeout(&self, timeout: Duration) -> bool {
1324 let mut state = self
1325 .state
1326 .lock()
1327 .unwrap_or_else(std::sync::PoisonError::into_inner);
1328 if state.pending.is_empty() {
1329 state.leader_active = false;
1330 return false;
1331 }
1332 let hard_deadline = Instant::now() + timeout.saturating_mul(4);
1333 loop {
1334 let member_count = state
1335 .pending
1336 .iter()
1337 .fold(0_usize, |count, job| count.saturating_add(job.member_count));
1338 if member_count >= MAX_SQL_WRITE_BATCH_MEMBERS
1339 || state.pending_encoded_bytes >= MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES
1340 {
1341 return true;
1342 }
1343 let remaining = hard_deadline.saturating_duration_since(Instant::now());
1344 if remaining.is_zero() {
1345 return true;
1346 }
1347 let observed_calls = state.pending.len();
1348 let (next, quiet) = self
1349 .changed
1350 .wait_timeout_while(state, timeout.min(remaining), |state| {
1351 state.pending.len() == observed_calls
1352 && state
1353 .pending
1354 .iter()
1355 .fold(0_usize, |count, job| count.saturating_add(job.member_count))
1356 < MAX_SQL_WRITE_BATCH_MEMBERS
1357 && state.pending_encoded_bytes < MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES
1358 })
1359 .unwrap_or_else(std::sync::PoisonError::into_inner);
1360 state = next;
1361 if quiet.timed_out() {
1362 return true;
1363 }
1364 }
1365 }
1366
1367 fn fail_pending(&self, error: NodeError) {
1368 let jobs = {
1369 let mut state = self
1370 .state
1371 .lock()
1372 .unwrap_or_else(std::sync::PoisonError::into_inner);
1373 state.leader_active = false;
1374 state.pending_encoded_bytes = 0;
1375 state.pending.drain(..).collect::<Vec<_>>()
1376 };
1377 for job in jobs {
1378 job.publish(Err(error.clone()));
1379 }
1380 }
1381
1382 #[cfg(test)]
1383 fn wait_for_pending_calls(&self, expected: usize, timeout: Duration) {
1384 let state = self
1385 .state
1386 .lock()
1387 .unwrap_or_else(std::sync::PoisonError::into_inner);
1388 let (state, timed_out) = self
1389 .changed
1390 .wait_timeout_while(state, timeout, |state| state.pending.len() != expected)
1391 .unwrap_or_else(std::sync::PoisonError::into_inner);
1392 assert!(
1393 !timed_out.timed_out(),
1394 "expected {expected} pending SQL group commit calls, got {}",
1395 state.pending.len()
1396 );
1397 }
1398}
1399
1400#[cfg(feature = "kv")]
1401type KvGroupCommitResult = Result<Vec<Result<ClientWriteResponse, NodeError>>, NodeError>;
1402
1403#[cfg(feature = "kv")]
1404struct KvGroupCommitJob {
1405 member_count: usize,
1406 encoded_bytes: usize,
1407 members: Mutex<Option<Vec<RuntimeBatchMember>>>,
1408 result: Mutex<Option<KvGroupCommitResult>>,
1409 changed: Condvar,
1410}
1411
1412#[cfg(feature = "kv")]
1413impl KvGroupCommitJob {
1414 fn new(members: Vec<RuntimeBatchMember>) -> Self {
1415 Self {
1416 member_count: members.len(),
1417 encoded_bytes: members.iter().fold(0_usize, |bytes, member| {
1418 bytes.saturating_add(member.payload.len())
1419 }),
1420 members: Mutex::new(Some(members)),
1421 result: Mutex::new(None),
1422 changed: Condvar::new(),
1423 }
1424 }
1425
1426 fn take_members(&self) -> Result<Vec<RuntimeBatchMember>, NodeError> {
1427 self.members
1428 .lock()
1429 .unwrap_or_else(std::sync::PoisonError::into_inner)
1430 .take()
1431 .ok_or_else(|| {
1432 NodeError::Invariant("KV group commit job members were already consumed".into())
1433 })
1434 }
1435
1436 fn publish(&self, result: KvGroupCommitResult) {
1437 let mut slot = self
1438 .result
1439 .lock()
1440 .unwrap_or_else(std::sync::PoisonError::into_inner);
1441 if slot.is_none() {
1442 *slot = Some(result);
1443 self.changed.notify_all();
1444 }
1445 }
1446
1447 fn wait(&self, cancelled: &AtomicBool) -> KvGroupCommitResult {
1448 let mut result = self
1449 .result
1450 .lock()
1451 .unwrap_or_else(std::sync::PoisonError::into_inner);
1452 loop {
1453 if let Some(result) = result.take() {
1454 return result;
1455 }
1456 if cancelled.load(Ordering::Acquire) {
1457 return Err(NodeError::Unavailable(
1458 "KV group commit cancelled during shutdown".into(),
1459 ));
1460 }
1461 result = self
1462 .changed
1463 .wait(result)
1464 .unwrap_or_else(std::sync::PoisonError::into_inner);
1465 }
1466 }
1467}
1468
1469#[cfg(feature = "kv")]
1470struct KvGroupCommitQueue {
1471 state: Mutex<KvGroupCommitQueueState>,
1472 changed: Condvar,
1473}
1474
1475#[cfg(feature = "kv")]
1476#[derive(Default)]
1477struct KvGroupCommitQueueState {
1478 pending: VecDeque<Arc<KvGroupCommitJob>>,
1479 pending_encoded_bytes: usize,
1480 leader_active: bool,
1481}
1482
1483#[cfg(feature = "kv")]
1484impl KvGroupCommitQueue {
1485 fn new() -> Self {
1486 Self {
1487 state: Mutex::new(KvGroupCommitQueueState::default()),
1488 changed: Condvar::new(),
1489 }
1490 }
1491
1492 fn enqueue(
1493 &self,
1494 members: Vec<RuntimeBatchMember>,
1495 cancelled: &AtomicBool,
1496 ) -> Result<(Arc<KvGroupCommitJob>, bool), NodeError> {
1497 if cancelled.load(Ordering::Acquire) {
1498 return Err(NodeError::Unavailable(
1499 "KV group commit is unavailable during shutdown".into(),
1500 ));
1501 }
1502 let job = Arc::new(KvGroupCommitJob::new(members));
1503 if job.member_count == 0 || job.member_count > MAX_KV_BATCH_MEMBERS {
1504 return Err(NodeError::InvalidRequest(format!(
1505 "KV group commit call must contain 1..={MAX_KV_BATCH_MEMBERS} members"
1506 )));
1507 }
1508 if job.encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES {
1509 return Err(NodeError::ResourceExhausted(format!(
1510 "KV group commit call exceeds {MAX_KV_GROUP_COMMIT_PENDING_BYTES} encoded bytes"
1511 )));
1512 }
1513 let mut state = self
1514 .state
1515 .lock()
1516 .unwrap_or_else(std::sync::PoisonError::into_inner);
1517 if state.pending.len() >= KV_GROUP_COMMIT_QUEUE_CAPACITY {
1518 return Err(NodeError::ResourceExhausted(format!(
1519 "KV group commit queue is full (capacity {KV_GROUP_COMMIT_QUEUE_CAPACITY})"
1520 )));
1521 }
1522 let pending_encoded_bytes = state
1523 .pending_encoded_bytes
1524 .saturating_add(job.encoded_bytes);
1525 if pending_encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES {
1526 return Err(NodeError::ResourceExhausted(format!(
1527 "KV group commit queue exceeds {MAX_KV_GROUP_COMMIT_PENDING_BYTES} pending encoded bytes"
1528 )));
1529 }
1530 state.pending_encoded_bytes = pending_encoded_bytes;
1531 state.pending.push_back(Arc::clone(&job));
1532 self.changed.notify_all();
1533 let leader = !state.leader_active;
1534 if leader {
1535 state.leader_active = true;
1536 }
1537 Ok((job, leader))
1538 }
1539
1540 fn drain_next_group(&self) -> Option<Vec<Arc<KvGroupCommitJob>>> {
1541 let mut state = self
1542 .state
1543 .lock()
1544 .unwrap_or_else(std::sync::PoisonError::into_inner);
1545 if state.pending.is_empty() {
1546 state.leader_active = false;
1547 return None;
1548 }
1549 let mut member_count = 0_usize;
1550 let mut encoded_bytes = 0_usize;
1551 let mut jobs = Vec::new();
1552 while let Some(job) = state.pending.front() {
1553 let next_count = member_count.saturating_add(job.member_count);
1554 let next_encoded_bytes = encoded_bytes.saturating_add(job.encoded_bytes);
1555 if !jobs.is_empty()
1556 && (next_count > MAX_KV_GROUP_COMMIT_MEMBERS
1557 || next_encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES)
1558 {
1559 break;
1560 }
1561 let job = state.pending.pop_front().expect("front job exists");
1562 member_count = next_count;
1563 encoded_bytes = next_encoded_bytes;
1564 state.pending_encoded_bytes = state
1565 .pending_encoded_bytes
1566 .checked_sub(job.encoded_bytes)
1567 .expect("queued KV byte reservation covers every pending job");
1568 jobs.push(job);
1569 }
1570 Some(jobs)
1571 }
1572
1573 fn collect_until_full_or_timeout(&self, timeout: Duration) -> bool {
1574 let mut state = self
1575 .state
1576 .lock()
1577 .unwrap_or_else(std::sync::PoisonError::into_inner);
1578 if state.pending.is_empty() {
1579 state.leader_active = false;
1580 return false;
1581 }
1582 let hard_deadline = Instant::now() + timeout.saturating_mul(4);
1583 loop {
1584 let member_count = state
1585 .pending
1586 .iter()
1587 .fold(0_usize, |count, job| count.saturating_add(job.member_count));
1588 if member_count >= MAX_KV_GROUP_COMMIT_MEMBERS
1589 || state.pending_encoded_bytes >= MAX_KV_GROUP_COMMIT_PENDING_BYTES
1590 {
1591 return true;
1592 }
1593 let remaining = hard_deadline.saturating_duration_since(Instant::now());
1594 if remaining.is_zero() {
1595 return true;
1596 }
1597 let observed_calls = state.pending.len();
1598 let (next, quiet) = self
1599 .changed
1600 .wait_timeout_while(state, timeout.min(remaining), |state| {
1601 state.pending.len() == observed_calls
1602 && state
1603 .pending
1604 .iter()
1605 .fold(0_usize, |count, job| count.saturating_add(job.member_count))
1606 < MAX_KV_GROUP_COMMIT_MEMBERS
1607 && state.pending_encoded_bytes < MAX_KV_GROUP_COMMIT_PENDING_BYTES
1608 })
1609 .unwrap_or_else(std::sync::PoisonError::into_inner);
1610 state = next;
1611 if quiet.timed_out() {
1612 return true;
1613 }
1614 }
1615 }
1616
1617 fn fail_pending(&self, error: NodeError) {
1618 let jobs = {
1619 let mut state = self
1620 .state
1621 .lock()
1622 .unwrap_or_else(std::sync::PoisonError::into_inner);
1623 state.leader_active = false;
1624 state.pending_encoded_bytes = 0;
1625 state.pending.drain(..).collect::<Vec<_>>()
1626 };
1627 for job in jobs {
1628 job.publish(Err(error.clone()));
1629 }
1630 }
1631
1632 #[cfg(test)]
1633 fn wait_for_pending_calls(&self, expected: usize, timeout: Duration) {
1634 let state = self
1635 .state
1636 .lock()
1637 .unwrap_or_else(std::sync::PoisonError::into_inner);
1638 let (state, timed_out) = self
1639 .changed
1640 .wait_timeout_while(state, timeout, |state| state.pending.len() != expected)
1641 .unwrap_or_else(std::sync::PoisonError::into_inner);
1642 assert!(
1643 !timed_out.timed_out(),
1644 "expected {expected} pending KV group commit calls, got {}",
1645 state.pending.len()
1646 );
1647 }
1648}
1649
1650#[cfg(any(feature = "graph", feature = "kv"))]
1651fn classify_pending_request(
1652 canonical_by_request: &mut HashMap<String, usize>,
1653 members: &[RuntimeBatchMember],
1654 index: usize,
1655 request_id: &str,
1656) -> Result<Option<usize>, NodeError> {
1657 let Some(&canonical) = canonical_by_request.get(request_id) else {
1658 canonical_by_request.insert(request_id.to_owned(), index);
1659 return Ok(None);
1660 };
1661 if members[canonical].payload == members[index].payload {
1662 Ok(Some(canonical))
1663 } else {
1664 Err(NodeError::InvalidRequest(format!(
1665 "request id {request_id:?} was reused with another command in the same writer batch"
1666 )))
1667 }
1668}
1669
1670impl ClientWriteResponse {
1671 fn applied_index(&self) -> LogIndex {
1672 match self {
1673 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1674 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
1675 #[cfg(feature = "sql")]
1676 Self::KeyValue(response) => response.applied_index,
1677 #[cfg(feature = "sql")]
1678 Self::Sql(response) => response.applied_index,
1679 #[cfg(feature = "graph")]
1680 Self::Graph(response) => response.applied_index(),
1681 #[cfg(feature = "kv")]
1682 Self::Kv(response) => response.applied_index(),
1683 }
1684 }
1685}
1686
1687#[cfg(feature = "sql")]
1688#[derive(Clone)]
1689pub struct NodeService {
1690 runtime: Arc<NodeRuntime>,
1691 coordinator: Option<Arc<CheckpointCoordinator>>,
1692 #[cfg(feature = "sql")]
1693 sql_reads_in_flight: Arc<AtomicUsize>,
1694}
1695
1696#[cfg(feature = "sql")]
1697struct SqlReadActivity {
1698 active: Arc<AtomicUsize>,
1699}
1700
1701#[cfg(feature = "sql")]
1702impl SqlReadActivity {
1703 fn enter(active: &Arc<AtomicUsize>) -> (Self, bool) {
1704 let previous = active.fetch_add(1, Ordering::Relaxed);
1705 debug_assert_ne!(previous, usize::MAX);
1706 (
1707 Self {
1708 active: active.clone(),
1709 },
1710 previous == 0,
1711 )
1712 }
1713}
1714
1715#[cfg(feature = "sql")]
1716impl Drop for SqlReadActivity {
1717 fn drop(&mut self) {
1718 let previous = self.active.fetch_sub(1, Ordering::Relaxed);
1719 debug_assert!(previous > 0);
1720 }
1721}
1722
1723#[cfg(feature = "sql")]
1724impl NodeService {
1725 pub fn new(runtime: Arc<NodeRuntime>, coordinator: Option<Arc<CheckpointCoordinator>>) -> Self {
1726 Self {
1727 runtime,
1728 coordinator,
1729 #[cfg(feature = "sql")]
1730 sql_reads_in_flight: Arc::new(AtomicUsize::new(0)),
1731 }
1732 }
1733
1734 #[cfg(feature = "sql")]
1735 pub async fn put(
1736 &self,
1737 request_id: &str,
1738 key: &str,
1739 value: &str,
1740 ) -> Result<WriteResponse, NodeError> {
1741 self.write(WriteRequest {
1742 request_id: request_id.into(),
1743 key: key.into(),
1744 value: value.into(),
1745 })
1746 .await
1747 }
1748
1749 #[cfg(feature = "sql")]
1750 pub async fn write(&self, request: WriteRequest) -> Result<WriteResponse, NodeError> {
1751 self.write_allowed()?;
1752 let runtime = self.runtime.clone();
1753 let response = tokio::task::spawn_blocking(move || {
1754 runtime.write(&request.request_id, &request.key, &request.value)
1755 })
1756 .await
1757 .map_err(node_service_task_error)??;
1758 self.confirm_committed(response.applied_index).await?;
1759 Ok(response)
1760 }
1761
1762 #[cfg(feature = "sql")]
1763 pub async fn execute_sql(&self, command: SqlCommand) -> Result<SqlExecuteResponse, NodeError> {
1764 self.write_allowed()?;
1765 let runtime = self.runtime.clone();
1766 let response =
1767 tokio::task::spawn_blocking(move || runtime.execute_sql_with_results(command))
1768 .await
1769 .map_err(node_service_task_error)??;
1770 self.confirm_committed(response.applied_index).await?;
1771 Ok(response)
1772 }
1773
1774 #[cfg(feature = "sql")]
1775 pub async fn read(
1776 &self,
1777 key: &str,
1778 consistency: ReadConsistency,
1779 ) -> Result<ReadResponse, NodeError> {
1780 let runtime = self.runtime.clone();
1781 let key = key.to_owned();
1782 self.run_sql_read_operation(consistency, move || runtime.read(&key, consistency))
1783 .await
1784 .map_err(node_service_task_error)?
1785 }
1786
1787 #[cfg(feature = "sql")]
1788 pub async fn query(
1789 &self,
1790 statement: SqlStatement,
1791 consistency: ReadConsistency,
1792 max_rows: u32,
1793 ) -> Result<SqlQueryResponse, NodeError> {
1794 let runtime = self.runtime.clone();
1795 self.run_sql_read_operation(consistency, move || {
1796 runtime.query_sql(&statement, consistency, max_rows)
1797 })
1798 .await
1799 .map_err(node_service_task_error)?
1800 }
1801
1802 #[cfg(feature = "sql")]
1803 async fn run_sql_read_operation<F, T>(
1804 &self,
1805 consistency: ReadConsistency,
1806 operation: F,
1807 ) -> Result<T, tokio::task::JoinError>
1808 where
1809 F: FnOnce() -> T + Send + 'static,
1810 T: Send + 'static,
1811 {
1812 if consistency == ReadConsistency::ReadBarrier {
1813 return run_read_operation(consistency, operation).await;
1814 }
1815 let (activity, sole_read) = SqlReadActivity::enter(&self.sql_reads_in_flight);
1816 let operation = move || {
1817 let _activity = activity;
1818 operation()
1819 };
1820 if sole_read {
1821 run_read_operation(consistency, operation).await
1822 } else {
1823 tokio::task::spawn_blocking(operation).await
1824 }
1825 }
1826
1827 #[cfg(feature = "sql")]
1828 fn write_allowed(&self) -> Result<(), NodeError> {
1829 self.coordinator
1830 .as_ref()
1831 .map_or(Ok(()), |coordinator| coordinator.write_allowed())
1832 .map_err(|error| NodeError::Unavailable(error.to_string()))
1833 }
1834
1835 #[cfg(feature = "sql")]
1836 async fn confirm_committed(&self, index: LogIndex) -> Result<(), NodeError> {
1837 confirm_write_durability(self.runtime.as_ref(), self.coordinator.as_deref(), index)
1838 .await
1839 .map_err(|error| NodeError::Unavailable(error.to_string()))
1840 }
1841}
1842
1843#[cfg(feature = "sql")]
1844fn node_service_task_error(error: tokio::task::JoinError) -> NodeError {
1845 NodeError::Fatal(format!("node service task failed: {error}"))
1846}
1847
1848#[doc(hidden)]
1849pub async fn run_read_operation<F, T>(
1850 consistency: ReadConsistency,
1851 operation: F,
1852) -> Result<T, tokio::task::JoinError>
1853where
1854 F: FnOnce() -> T + Send + 'static,
1855 T: Send + 'static,
1856{
1857 if consistency != ReadConsistency::ReadBarrier
1858 && matches!(
1859 tokio::runtime::Handle::current().runtime_flavor(),
1860 tokio::runtime::RuntimeFlavor::MultiThread
1861 )
1862 {
1863 Ok(tokio::task::block_in_place(operation))
1864 } else {
1865 tokio::task::spawn_blocking(operation).await
1866 }
1867}
1868
1869#[derive(Clone)]
1870struct PeerGateState {
1871 peers: Vec<PeerConfig>,
1872 recovery_generation: u64,
1873 protocol_version: &'static str,
1874 slots: Arc<tokio::sync::Semaphore>,
1875}
1876
1877#[derive(Clone)]
1878struct ClientGateState {
1879 runtime: Arc<NodeRuntime>,
1880 slots: Arc<tokio::sync::Semaphore>,
1881 coordinator: Option<Arc<CheckpointCoordinator>>,
1882}
1883
1884#[derive(Clone)]
1885struct RuntimeLogPeer {
1886 runtime: Arc<NodeRuntime>,
1887}
1888
1889impl LogPeer for RuntimeLogPeer {
1890 fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
1891 self.runtime.fetch_log(request)
1892 }
1893}
1894
1895fn fetch_runtime_log(
1896 runtime: &NodeRuntime,
1897 request: FetchLogRequest,
1898) -> Result<FetchLogResponse, FetchLogError> {
1899 if request.from_index == 0 || request.max_entries > MAX_FETCH_ENTRIES {
1900 return Err(FetchLogError::InvalidRequest {
1901 message: "invalid fetch bounds".into(),
1902 });
1903 }
1904 let state = runtime
1905 .log_store
1906 .logical_state()
1907 .map_err(|error| FetchLogError::Transport {
1908 message: error.to_string(),
1909 })?;
1910 if let Some(anchor) = state.anchor {
1911 if request.from_index <= anchor.compacted().index() {
1912 return Err(FetchLogError::SnapshotRequired {
1913 anchor: Box::new(anchor),
1914 });
1915 }
1916 }
1917 let last_index = state.tip.map_or(0, |tip| tip.index());
1918 if request.max_entries == 0 || request.from_index > last_index {
1919 return Ok(FetchLogResponse {
1920 entries: Vec::new(),
1921 last_index,
1922 });
1923 }
1924 let end = request
1925 .from_index
1926 .saturating_add(u64::from(request.max_entries) - 1)
1927 .min(last_index);
1928 let range = IndexRange::new(request.from_index, end).map_err(|error| {
1929 FetchLogError::InvalidRequest {
1930 message: error.to_string(),
1931 }
1932 })?;
1933 let entries =
1934 runtime
1935 .log_store
1936 .read_range(range)
1937 .map_err(|error| FetchLogError::Transport {
1938 message: error.to_string(),
1939 })?;
1940 Ok(FetchLogResponse {
1941 entries,
1942 last_index,
1943 })
1944}
1945
1946pub fn recorder_router<R, P>(recorder: R, peers: P) -> Router
1947where
1948 R: RecorderRpc + Clone + Send + Sync + 'static,
1949 P: Into<Vec<PeerConfig>>,
1950{
1951 recorder_router_for_generation(recorder, peers, 1)
1952}
1953
1954pub fn recorder_router_for_generation<R, P>(
1955 recorder: R,
1956 peers: P,
1957 recovery_generation: u64,
1958) -> Router
1959where
1960 R: RecorderRpc + Clone + Send + Sync + 'static,
1961 P: Into<Vec<PeerConfig>>,
1962{
1963 recorder_routes(
1964 recorder,
1965 peers.into(),
1966 recovery_generation,
1967 Arc::new(tokio::sync::Semaphore::new(DEFAULT_PEER_CONCURRENCY)),
1968 )
1969 .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
1970}
1971
1972pub fn log_peer_router<P, C>(peer: P, peers: C) -> Router
1973where
1974 P: LogPeer + Clone + Send + Sync + 'static,
1975 C: Into<Vec<PeerConfig>>,
1976{
1977 log_peer_router_for_generation(peer, peers, 1)
1978}
1979
1980pub fn log_peer_router_for_generation<P, C>(peer: P, peers: C, recovery_generation: u64) -> Router
1981where
1982 P: LogPeer + Clone + Send + Sync + 'static,
1983 C: Into<Vec<PeerConfig>>,
1984{
1985 log_routes(
1986 peer,
1987 peers.into(),
1988 recovery_generation,
1989 Arc::new(tokio::sync::Semaphore::new(DEFAULT_PEER_CONCURRENCY)),
1990 )
1991 .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
1992}
1993
1994pub fn node_rpc_router<R, P, C>(recorder: R, peer: P, peers: C) -> Router
1995where
1996 R: RecorderRpc + Clone + Send + Sync + 'static,
1997 P: LogPeer + Clone + Send + Sync + 'static,
1998 C: Into<Vec<PeerConfig>>,
1999{
2000 node_rpc_router_for_generation(recorder, peer, peers, 1)
2001}
2002
2003pub fn node_rpc_router_for_generation<R, P, C>(
2004 recorder: R,
2005 peer: P,
2006 peers: C,
2007 recovery_generation: u64,
2008) -> Router
2009where
2010 R: RecorderRpc + Clone + Send + Sync + 'static,
2011 P: LogPeer + Clone + Send + Sync + 'static,
2012 C: Into<Vec<PeerConfig>>,
2013{
2014 node_rpc_router_with_limits_for_generation(
2015 recorder,
2016 peer,
2017 peers,
2018 DEFAULT_PEER_CONCURRENCY,
2019 DEFAULT_PEER_CONCURRENCY,
2020 recovery_generation,
2021 )
2022}
2023
2024pub fn node_rpc_router_with_limits<R, P, C>(
2025 recorder: R,
2026 peer: P,
2027 peers: C,
2028 recorder_concurrency: usize,
2029 log_concurrency: usize,
2030) -> Router
2031where
2032 R: RecorderRpc + Clone + Send + Sync + 'static,
2033 P: LogPeer + Clone + Send + Sync + 'static,
2034 C: Into<Vec<PeerConfig>>,
2035{
2036 node_rpc_router_with_limits_for_generation(
2037 recorder,
2038 peer,
2039 peers,
2040 recorder_concurrency,
2041 log_concurrency,
2042 1,
2043 )
2044}
2045
2046pub fn node_rpc_router_with_limits_for_generation<R, P, C>(
2047 recorder: R,
2048 peer: P,
2049 peers: C,
2050 recorder_concurrency: usize,
2051 log_concurrency: usize,
2052 recovery_generation: u64,
2053) -> Router
2054where
2055 R: RecorderRpc + Clone + Send + Sync + 'static,
2056 P: LogPeer + Clone + Send + Sync + 'static,
2057 C: Into<Vec<PeerConfig>>,
2058{
2059 let peers = peers.into();
2060 let recorder_slots = Arc::new(tokio::sync::Semaphore::new(recorder_concurrency));
2061 let log_slots = Arc::new(tokio::sync::Semaphore::new(log_concurrency));
2062 recorder_routes(recorder, peers.clone(), recovery_generation, recorder_slots)
2063 .merge(log_routes(peer, peers, recovery_generation, log_slots))
2064 .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
2065}
2066
2067pub fn node_router<R>(runtime: Arc<NodeRuntime>, recorder: R) -> Router
2068where
2069 R: RecorderRpc + Clone + Send + Sync + 'static,
2070{
2071 node_router_with_limits(
2072 runtime,
2073 recorder,
2074 DEFAULT_CLIENT_CONCURRENCY,
2075 DEFAULT_PEER_CONCURRENCY,
2076 )
2077}
2078
2079pub fn node_router_with_limits<R>(
2080 runtime: Arc<NodeRuntime>,
2081 recorder: R,
2082 client_concurrency: usize,
2083 peer_concurrency: usize,
2084) -> Router
2085where
2086 R: RecorderRpc + Clone + Send + Sync + 'static,
2087{
2088 node_router_with_optional_checkpoint(
2089 runtime,
2090 recorder,
2091 None,
2092 client_concurrency,
2093 peer_concurrency,
2094 )
2095}
2096
2097pub fn node_router_with_checkpoint<R>(
2098 runtime: Arc<NodeRuntime>,
2099 recorder: R,
2100 coordinator: Arc<CheckpointCoordinator>,
2101) -> Router
2102where
2103 R: RecorderRpc + Clone + Send + Sync + 'static,
2104{
2105 node_router_with_checkpoint_and_limits(
2106 runtime,
2107 recorder,
2108 coordinator,
2109 DEFAULT_CLIENT_CONCURRENCY,
2110 DEFAULT_PEER_CONCURRENCY,
2111 )
2112}
2113
2114pub fn node_router_with_checkpoint_and_limits<R>(
2115 runtime: Arc<NodeRuntime>,
2116 recorder: R,
2117 coordinator: Arc<CheckpointCoordinator>,
2118 client_concurrency: usize,
2119 peer_concurrency: usize,
2120) -> Router
2121where
2122 R: RecorderRpc + Clone + Send + Sync + 'static,
2123{
2124 node_router_with_optional_checkpoint(
2125 runtime,
2126 recorder,
2127 Some(coordinator),
2128 client_concurrency,
2129 peer_concurrency,
2130 )
2131}
2132
2133fn node_router_with_optional_checkpoint<R>(
2134 runtime: Arc<NodeRuntime>,
2135 recorder: R,
2136 coordinator: Option<Arc<CheckpointCoordinator>>,
2137 client_concurrency: usize,
2138 peer_concurrency: usize,
2139) -> Router
2140where
2141 R: RecorderRpc + Clone + Send + Sync + 'static,
2142{
2143 let peers = runtime.config.peers.clone();
2144 let recovery_generation = runtime.config.recovery_generation();
2145 let client_slots = Arc::new(tokio::sync::Semaphore::new(client_concurrency));
2146 let recorder_slots = Arc::new(tokio::sync::Semaphore::new(peer_concurrency));
2147 let log_slots = Arc::new(tokio::sync::Semaphore::new(peer_concurrency));
2148 let write_operations = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
2149 let (writer, writer_receiver) = tokio::sync::mpsc::channel(client_concurrency.max(1));
2150 tokio::spawn(writer_loop(
2151 Arc::downgrade(&runtime),
2152 coordinator.clone(),
2153 write_operations.clone(),
2154 writer_receiver,
2155 runtime.config.writer_batch_max,
2156 runtime.config.writer_batch_window,
2157 ));
2158 #[cfg(any(feature = "sql", feature = "graph", feature = "kv"))]
2159 let client_routes: Router = match runtime.config().execution_profile() {
2160 ExecutionProfile::Sqlite => {
2161 #[cfg(feature = "sql")]
2162 {
2163 Router::new()
2164 .route(WRITE_PATH, post(handle_write))
2165 .route(READ_PATH, post(handle_read))
2166 .route(SQL_EXECUTE_PATH, post(handle_sql_execute))
2167 .route(SQL_QUERY_PATH, post(handle_sql_query))
2168 }
2169 #[cfg(not(feature = "sql"))]
2170 unreachable!("SQL runtime cannot open without the sql feature")
2171 }
2172 ExecutionProfile::Graph => {
2173 #[cfg(feature = "graph")]
2174 {
2175 Router::new()
2176 .route(GRAPH_PUT_DOCUMENT_PATH, post(handle_graph_put_document))
2177 .route(
2178 GRAPH_DELETE_DOCUMENT_PATH,
2179 post(handle_graph_delete_document),
2180 )
2181 .route(GRAPH_GET_DOCUMENT_PATH, post(handle_graph_get_document))
2182 .route(GRAPH_QUERY_PATH, post(handle_graph_query))
2183 }
2184 #[cfg(not(feature = "graph"))]
2185 unreachable!("graph runtime cannot open without the graph feature")
2186 }
2187 ExecutionProfile::Kv => {
2188 #[cfg(feature = "kv")]
2189 {
2190 Router::new()
2191 .route(KV_PUT_PATH, post(handle_kv_put))
2192 .route(KV_DELETE_PATH, post(handle_kv_delete))
2193 .route(KV_GET_PATH, post(handle_kv_get))
2194 .route(KV_SCAN_PATH, post(handle_kv_scan))
2195 }
2196 #[cfg(not(feature = "kv"))]
2197 unreachable!("KV runtime cannot open without the kv feature")
2198 }
2199 }
2200 .route_layer(middleware::from_fn_with_state(
2201 ClientGateState {
2202 runtime: runtime.clone(),
2203 slots: client_slots,
2204 coordinator: coordinator.clone(),
2205 },
2206 client_gate,
2207 ))
2208 .with_state(NodeRouteState {
2209 runtime: runtime.clone(),
2210 coordinator: coordinator.clone(),
2211 write_operations: write_operations.clone(),
2212 writer: writer.clone(),
2213 });
2214 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
2215 let client_routes: Router = Router::new();
2216 let health_routes = Router::new()
2217 .route(LIVEZ_PATH, get(handle_livez))
2218 .route(READYZ_PATH, get(handle_readyz))
2219 .with_state(NodeRouteState {
2220 runtime: runtime.clone(),
2221 coordinator,
2222 write_operations,
2223 writer,
2224 });
2225 recorder_routes(recorder, peers.clone(), recovery_generation, recorder_slots)
2226 .merge(log_routes(
2227 RuntimeLogPeer { runtime },
2228 peers,
2229 recovery_generation,
2230 log_slots,
2231 ))
2232 .merge(client_routes)
2233 .merge(health_routes)
2234 .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
2235}
2236
2237fn recorder_routes<R>(
2238 recorder: R,
2239 peers: Vec<PeerConfig>,
2240 recovery_generation: u64,
2241 slots: Arc<tokio::sync::Semaphore>,
2242) -> Router
2243where
2244 R: RecorderRpc + Clone + Send + Sync + 'static,
2245{
2246 let recorder_peers = peers.clone();
2247 Router::new()
2248 .route(RECORDER_IDENTITY_PATH, post(handle_recorder_identity::<R>))
2249 .route(
2250 RECORDER_STORE_COMMAND_PATH,
2251 post(handle_recorder_store_command::<R>),
2252 )
2253 .route(
2254 RECORDER_FETCH_COMMAND_PATH,
2255 post(handle_recorder_fetch_command::<R>),
2256 )
2257 .route(
2258 RECORDER_INSPECT_PROOF_PATH,
2259 post(handle_recorder_inspect_proof::<R>),
2260 )
2261 .route(
2262 RECORDER_INSPECT_RECORD_PATH,
2263 post(handle_recorder_inspect_record::<R>),
2264 )
2265 .route(
2266 RECORDER_READ_FENCE_PATH,
2267 post(handle_recorder_read_fence::<R>),
2268 )
2269 .route(RECORDER_RECORD_PATH, post(handle_recorder_record::<R>))
2270 .route(
2271 RECORDER_INSTALL_PROOF_PATH,
2272 post(handle_recorder_install_proof::<R>),
2273 )
2274 .route_layer(middleware::from_fn_with_state(
2275 PeerGateState {
2276 peers,
2277 recovery_generation,
2278 protocol_version: RECORDER_PROTOCOL_VERSION,
2279 slots,
2280 },
2281 peer_gate,
2282 ))
2283 .with_state(RecorderRouteState {
2284 recorder,
2285 peers: recorder_peers,
2286 })
2287}
2288
2289fn log_routes<P>(
2290 peer: P,
2291 peers: Vec<PeerConfig>,
2292 recovery_generation: u64,
2293 slots: Arc<tokio::sync::Semaphore>,
2294) -> Router
2295where
2296 P: LogPeer + Clone + Send + Sync + 'static,
2297{
2298 Router::new()
2299 .route(LOG_FETCH_PATH, post(handle_fetch_log::<P>))
2300 .route_layer(middleware::from_fn_with_state(
2301 PeerGateState {
2302 peers,
2303 recovery_generation,
2304 protocol_version: PROTOCOL_VERSION,
2305 slots,
2306 },
2307 peer_gate,
2308 ))
2309 .with_state(LogRouteState { peer })
2310}
2311
2312async fn handle_recorder_identity<R>(
2313 State(state): State<RecorderRouteState<R>>,
2314 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2315 Json(request): Json<RecorderWire<()>>,
2316) -> Response
2317where
2318 R: RecorderRpc + Clone + Send + Sync + 'static,
2319{
2320 if request.version != RECORDER_WIRE_VERSION {
2321 return StatusCode::BAD_REQUEST.into_response();
2322 }
2323 let recorder = state.recorder;
2324 recorder_v2_response(
2325 tokio::task::spawn_blocking(move || {
2326 let _permit = permit;
2327 recorder.recorder_id()
2328 })
2329 .await,
2330 )
2331}
2332
2333async fn handle_recorder_store_command<R>(
2334 State(state): State<RecorderRouteState<R>>,
2335 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2336 Json(request): Json<RecorderWire<StoreCommandV2>>,
2337) -> Response
2338where
2339 R: RecorderRpc + Clone + Send + Sync + 'static,
2340{
2341 if request.version != RECORDER_WIRE_VERSION || !valid_recorder_command(&request.body.command) {
2342 return StatusCode::BAD_REQUEST.into_response();
2343 }
2344 let recorder = state.recorder;
2345 recorder_v2_response(
2346 tokio::task::spawn_blocking(move || {
2347 let _permit = permit;
2348 let body = request.body;
2349 recorder.store_command_for(
2350 body.cluster_id,
2351 body.epoch,
2352 body.config_id,
2353 body.config_digest,
2354 body.command_hash,
2355 body.command,
2356 )
2357 })
2358 .await,
2359 )
2360}
2361
2362async fn handle_recorder_fetch_command<R>(
2363 State(state): State<RecorderRouteState<R>>,
2364 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2365 Json(request): Json<RecorderWire<FetchCommandV2>>,
2366) -> Response
2367where
2368 R: RecorderRpc + Clone + Send + Sync + 'static,
2369{
2370 if request.version != RECORDER_WIRE_VERSION {
2371 return StatusCode::BAD_REQUEST.into_response();
2372 }
2373 let recorder = state.recorder;
2374 recorder_v2_response(
2375 tokio::task::spawn_blocking(move || {
2376 let _permit = permit;
2377 let body = request.body;
2378 recorder.fetch_command_for(
2379 body.cluster_id,
2380 body.epoch,
2381 body.config_id,
2382 body.config_digest,
2383 body.command_hash,
2384 )
2385 })
2386 .await,
2387 )
2388}
2389
2390async fn handle_recorder_inspect_proof<R>(
2391 State(state): State<RecorderRouteState<R>>,
2392 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2393 Json(request): Json<RecorderWire<InspectProofV2>>,
2394) -> Response
2395where
2396 R: RecorderRpc + Clone + Send + Sync + 'static,
2397{
2398 if request.version != RECORDER_WIRE_VERSION {
2399 return StatusCode::BAD_REQUEST.into_response();
2400 }
2401 let recorder = state.recorder;
2402 recorder_v2_response(
2403 tokio::task::spawn_blocking(move || {
2404 let _permit = permit;
2405 recorder.inspect_decision_proof(request.body.slot)
2406 })
2407 .await,
2408 )
2409}
2410
2411async fn handle_recorder_inspect_record<R>(
2412 State(state): State<RecorderRouteState<R>>,
2413 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2414 Json(request): Json<RecorderWire<InspectProofV2>>,
2415) -> Response
2416where
2417 R: RecorderRpc + Clone + Send + Sync + 'static,
2418{
2419 if request.version != RECORDER_WIRE_VERSION {
2420 return StatusCode::BAD_REQUEST.into_response();
2421 }
2422 let recorder = state.recorder;
2423 recorder_v2_response(
2424 tokio::task::spawn_blocking(move || {
2425 let _permit = permit;
2426 recorder.inspect_record_summary(request.body.slot)
2427 })
2428 .await,
2429 )
2430}
2431
2432async fn handle_recorder_read_fence<R>(
2433 State(state): State<RecorderRouteState<R>>,
2434 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2435 Json(request): Json<RecorderWire<ReadFenceRequest>>,
2436) -> Response
2437where
2438 R: RecorderRpc + Clone + Send + Sync + 'static,
2439{
2440 if request.version != RECORDER_WIRE_VERSION {
2441 return StatusCode::BAD_REQUEST.into_response();
2442 }
2443 let recorder = state.recorder;
2444 recorder_v2_response(
2445 tokio::task::spawn_blocking(move || {
2446 let _permit = permit;
2447 recorder.observe_read_fence(request.body)
2448 })
2449 .await,
2450 )
2451}
2452
2453async fn handle_recorder_record<R>(
2454 State(state): State<RecorderRouteState<R>>,
2455 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2456 Extension(authenticated_peer): Extension<AuthenticatedPeer>,
2457 Json(request): Json<RecorderWire<RecordRequest>>,
2458) -> Response
2459where
2460 R: RecorderRpc + Clone + Send + Sync + 'static,
2461{
2462 if request.version != RECORDER_WIRE_VERSION || !valid_recorder_record(&request.body) {
2463 return StatusCode::BAD_REQUEST.into_response();
2464 }
2465 if !authenticated_proposer_admitted(
2466 &authenticated_peer.0,
2467 &request.body.proposal.proposer_id,
2468 &state.peers,
2469 ) {
2470 return recorder_v2_response::<RecordSummary>(Ok(Err(rhiza_quepaxa::Error::Rejected(
2471 RejectReason::InvalidRequest,
2472 ))));
2473 }
2474 let recorder = state.recorder;
2475 recorder_v2_response(
2476 tokio::task::spawn_blocking(move || {
2477 let _permit = permit;
2478 recorder.record(request.body)
2479 })
2480 .await,
2481 )
2482}
2483
2484fn valid_recorder_command(command: &StoredCommand) -> bool {
2485 command.payload.len() <= MAX_COMMAND_BYTES
2486}
2487
2488fn valid_recorder_record(request: &RecordRequest) -> bool {
2489 !request.cluster_id.is_empty()
2490 && request.cluster_id.len() <= MAX_REQUEST_ID_BYTES
2491 && request.command.as_ref().is_none_or(valid_recorder_command)
2492}
2493
2494fn authenticated_proposer_admitted(
2495 authenticated_peer_id: &str,
2496 proposer_id: &str,
2497 peers: &[PeerConfig],
2498) -> bool {
2499 peers
2502 .iter()
2503 .any(|peer| peer.node_id == authenticated_peer_id)
2504 && peers.iter().any(|peer| peer.node_id == proposer_id)
2505}
2506
2507async fn handle_recorder_install_proof<R>(
2508 State(state): State<RecorderRouteState<R>>,
2509 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2510 Extension(authenticated_peer): Extension<AuthenticatedPeer>,
2511 Json(request): Json<RecorderWire<InstallProofV2>>,
2512) -> Response
2513where
2514 R: RecorderRpc + Clone + Send + Sync + 'static,
2515{
2516 if request.version != RECORDER_WIRE_VERSION {
2517 return StatusCode::BAD_REQUEST.into_response();
2518 }
2519 if !authenticated_proposer_admitted(
2520 &authenticated_peer.0,
2521 &request.body.proof.proposal().proposer_id,
2522 &state.peers,
2523 ) {
2524 return recorder_v2_response::<()>(Ok(Err(rhiza_quepaxa::Error::Rejected(
2525 RejectReason::InvalidRequest,
2526 ))));
2527 }
2528 let recorder = state.recorder;
2529 recorder_v2_response(
2530 tokio::task::spawn_blocking(move || {
2531 let _permit = permit;
2532 let membership = Membership::from_voters(request.body.members)?;
2533 recorder.install_decision_proof(request.body.proof, &membership)
2534 })
2535 .await,
2536 )
2537}
2538
2539fn recorder_v2_response<T: serde::Serialize>(
2540 result: Result<rhiza_quepaxa::Result<T>, tokio::task::JoinError>,
2541) -> Response {
2542 let (status, body) = match result {
2543 Ok(Ok(value)) => (StatusCode::OK, RecorderV2Result::Ok(value)),
2544 Ok(Err(rhiza_quepaxa::Error::Rejected(reason))) => {
2545 (StatusCode::CONFLICT, RecorderV2Result::Rejected(reason))
2546 }
2547 Ok(Err(error)) => (
2548 recorder_error_status(&error),
2549 RecorderV2Result::Error(error.to_string()),
2550 ),
2551 Err(error) => (
2552 StatusCode::INTERNAL_SERVER_ERROR,
2553 RecorderV2Result::Error(error.to_string()),
2554 ),
2555 };
2556 (
2557 status,
2558 Json(RecorderWire {
2559 version: RECORDER_WIRE_VERSION,
2560 body,
2561 }),
2562 )
2563 .into_response()
2564}
2565
2566async fn handle_fetch_log<P>(
2567 State(state): State<LogRouteState<P>>,
2568 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2569 Json(request): Json<FetchLogRequest>,
2570) -> Response
2571where
2572 P: LogPeer + Clone + Send + Sync + 'static,
2573{
2574 if request.from_index == 0 || request.max_entries > MAX_FETCH_ENTRIES {
2575 return StatusCode::BAD_REQUEST.into_response();
2576 }
2577 let peer = state.peer;
2578 let result = tokio::task::spawn_blocking(move || {
2579 let _permit = permit;
2580 peer.fetch_log(request)
2581 })
2582 .await;
2583 match result {
2584 Ok(Ok(response)) => Json(FetchLogHttpResponse::Fetched(response)).into_response(),
2585 Ok(Err(error)) => (
2586 fetch_log_error_status(&error),
2587 Json(FetchLogHttpResponse::Failed(error)),
2588 )
2589 .into_response(),
2590 Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
2591 }
2592}
2593
2594#[cfg(feature = "sql")]
2595async fn handle_write(
2596 State(state): State<NodeRouteState>,
2597 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2598 request: Result<Json<WriteRequest>, JsonRejection>,
2599) -> Response {
2600 let request = match client_json(request) {
2601 Ok(request) => request,
2602 Err(response) => return response,
2603 };
2604 let payload = match canonical_put(&request.request_id, &request.key, &request.value) {
2605 Ok(payload) => payload,
2606 Err(error) => return node_error_response(error),
2607 };
2608 let request_id = request.request_id.clone();
2609 let operation = QueuedOperation::KeyValue {
2610 key: request.key,
2611 value: request.value,
2612 };
2613 coordinate_write(state, permit, request_id, payload, operation).await
2614}
2615
2616#[cfg(feature = "sql")]
2617async fn handle_sql_execute(
2618 State(state): State<NodeRouteState>,
2619 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2620 request: Result<Json<SqlExecuteRequest>, JsonRejection>,
2621) -> Response {
2622 let request = match client_json(request) {
2623 Ok(request) => request,
2624 Err(response) => return response,
2625 };
2626 if let Err(error) = validate_field(
2627 "request_id",
2628 &request.request_id,
2629 MAX_REQUEST_ID_BYTES,
2630 false,
2631 ) {
2632 return node_error_response(error);
2633 }
2634 let command = SqlCommand {
2635 request_id: request.request_id.clone(),
2636 statements: request.statements,
2637 };
2638 let payload = match encode_sql_command_with_index(&command) {
2639 Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
2640 Ok(_) => {
2641 return node_error_response(NodeError::InvalidRequest(format!(
2642 "command exceeds {MAX_COMMAND_BYTES} bytes"
2643 )))
2644 }
2645 Err(error) => return node_error_response(error),
2646 };
2647 let request_id = command.request_id.clone();
2648 coordinate_write(
2649 state,
2650 permit,
2651 request_id,
2652 payload,
2653 QueuedOperation::Sql(command),
2654 )
2655 .await
2656}
2657
2658async fn coordinate_write(
2659 state: NodeRouteState,
2660 permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2661 request_id: String,
2662 payload: Vec<u8>,
2663 operation: QueuedOperation,
2664) -> Response {
2665 let deadline = tokio::time::Instant::now() + CLIENT_WRITE_WAIT_TIMEOUT;
2666 let (mut receiver, queued) = {
2667 let mut operations = state.write_operations.lock().await;
2668 if let Some(operation) = operations.get(&request_id) {
2669 if operation.payload != payload {
2670 return client_error_response(
2671 StatusCode::CONFLICT,
2672 "request_conflict",
2673 false,
2674 "request id is already in flight with a different payload",
2675 None,
2676 );
2677 }
2678 (operation.result.clone(), None)
2679 } else {
2680 let (sender, receiver) = tokio::sync::watch::channel(None);
2681 operations.insert(
2682 request_id.clone(),
2683 WriteOperation {
2684 payload: payload.clone(),
2685 result: receiver.clone(),
2686 },
2687 );
2688 (
2689 receiver,
2690 Some(QueuedWrite {
2691 request_id: request_id.clone(),
2692 payload,
2693 operation,
2694 permit,
2695 sender,
2696 }),
2697 )
2698 }
2699 };
2700 if let Some(queued) = queued {
2701 match tokio::time::timeout_at(deadline, state.writer.send(queued)).await {
2702 Ok(Ok(())) => {}
2703 Ok(Err(_)) => {
2704 state.write_operations.lock().await.remove(&request_id);
2705 return client_error_response(
2706 StatusCode::SERVICE_UNAVAILABLE,
2707 "durability_unavailable",
2708 true,
2709 "writer queue is unavailable",
2710 None,
2711 );
2712 }
2713 Err(_) => {
2714 state.write_operations.lock().await.remove(&request_id);
2715 return client_error_response(
2716 StatusCode::SERVICE_UNAVAILABLE,
2717 "write_timeout",
2718 true,
2719 "write did not enter the queue before the response deadline",
2720 None,
2721 );
2722 }
2723 }
2724 }
2725 let wait = async {
2726 loop {
2727 if let Some(result) = receiver.borrow().clone() {
2728 return result;
2729 }
2730 if receiver.changed().await.is_err() {
2731 return WriteOperationResult::DurabilityUnavailable;
2732 }
2733 }
2734 };
2735 match tokio::time::timeout_at(deadline, wait).await {
2736 Ok(WriteOperationResult::Runtime(Ok(response))) => match response {
2737 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
2738 ClientWriteResponse::Unavailable => {
2739 unreachable!("no execution profiles are compiled in")
2740 }
2741 #[cfg(feature = "sql")]
2742 ClientWriteResponse::KeyValue(response) => Json(response).into_response(),
2743 #[cfg(feature = "sql")]
2744 ClientWriteResponse::Sql(response) => Json(response).into_response(),
2745 #[cfg(feature = "graph")]
2746 ClientWriteResponse::Graph(outcome) => {
2747 Json(graph_mutation_response(outcome)).into_response()
2748 }
2749 #[cfg(feature = "kv")]
2750 ClientWriteResponse::Kv(outcome) => Json(kv_mutation_response(outcome)).into_response(),
2751 },
2752 Ok(WriteOperationResult::Runtime(Err(error))) => node_error_response(error),
2753 Ok(WriteOperationResult::DurabilityUnavailable) => client_error_response(
2754 StatusCode::SERVICE_UNAVAILABLE,
2755 "durability_unavailable",
2756 true,
2757 "durability confirmation is unavailable",
2758 None,
2759 ),
2760 Err(_) => client_error_response(
2761 StatusCode::SERVICE_UNAVAILABLE,
2762 "write_timeout",
2763 true,
2764 "write did not complete before the response deadline",
2765 None,
2766 ),
2767 }
2768}
2769
2770async fn writer_loop(
2771 runtime: std::sync::Weak<NodeRuntime>,
2772 coordinator: Option<Arc<CheckpointCoordinator>>,
2773 write_operations: Arc<tokio::sync::Mutex<HashMap<String, WriteOperation>>>,
2774 mut receiver: tokio::sync::mpsc::Receiver<QueuedWrite>,
2775 batch_max: usize,
2776 batch_window: Duration,
2777) {
2778 while let Some(first) = receiver.recv().await {
2779 let mut queued = Vec::with_capacity(batch_max);
2780 queued.push(first);
2781 let deadline = tokio::time::Instant::now() + batch_window;
2782 while queued.len() < batch_max {
2783 match tokio::time::timeout_at(deadline, receiver.recv()).await {
2784 Ok(Some(next)) => queued.push(next),
2785 Ok(None) | Err(_) => break,
2786 }
2787 }
2788
2789 let mut dispatch = Vec::with_capacity(queued.len());
2790 let mut members = Vec::with_capacity(queued.len());
2791 for queued in queued {
2792 members.push(RuntimeBatchMember {
2793 #[cfg(feature = "sql")]
2794 request_id: queued.request_id.clone(),
2795 payload: queued.payload,
2796 operation: queued.operation,
2797 });
2798 dispatch.push((queued.request_id, queued.sender, queued.permit));
2799 }
2800
2801 let Some(runtime) = runtime.upgrade() else {
2802 for (request_id, sender, _permit) in dispatch {
2803 sender.send_replace(Some(WriteOperationResult::DurabilityUnavailable));
2804 write_operations.lock().await.remove(&request_id);
2805 }
2806 break;
2807 };
2808
2809 let blocking_runtime = runtime.clone();
2810 let results =
2811 tokio::task::spawn_blocking(move || blocking_runtime.execute_client_batch(members))
2812 .await
2813 .unwrap_or_else(|error| {
2814 (0..dispatch.len())
2815 .map(|_| {
2816 Err(NodeError::Fatal(format!(
2817 "writer batch task failed: {error}"
2818 )))
2819 })
2820 .collect()
2821 });
2822 let dispatch = dispatch
2823 .into_iter()
2824 .map(|(request_id, sender, permit)| {
2825 drop(permit);
2826 (request_id, sender)
2827 })
2828 .collect::<Vec<_>>();
2829
2830 let committed_index = results
2831 .iter()
2832 .filter_map(|result| result.as_ref().ok().map(ClientWriteResponse::applied_index))
2833 .max();
2834 let durability_available = if let Some(index) = committed_index {
2835 confirm_write_durability(runtime.as_ref(), coordinator.as_deref(), index)
2836 .await
2837 .is_ok()
2838 } else {
2839 true
2840 };
2841
2842 for ((request_id, sender), result) in dispatch.into_iter().zip(results) {
2843 let delivered = if !durability_available && result.is_ok() {
2844 WriteOperationResult::DurabilityUnavailable
2845 } else {
2846 WriteOperationResult::Runtime(result)
2847 };
2848 sender.send_replace(Some(delivered));
2849 write_operations.lock().await.remove(&request_id);
2850 }
2851 }
2852}
2853
2854#[cfg(feature = "sql")]
2855async fn handle_sql_query(
2856 State(state): State<NodeRouteState>,
2857 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2858 request: Result<Json<SqlQueryRequest>, JsonRejection>,
2859) -> Response {
2860 let request = match client_json(request) {
2861 Ok(request) => request,
2862 Err(response) => return response,
2863 };
2864 let runtime = state.runtime;
2865 let consistency = request
2866 .consistency
2867 .unwrap_or(runtime.config.read_consistency());
2868 let max_rows = request.max_rows.unwrap_or(DEFAULT_SQL_MAX_ROWS);
2869 if max_rows == 0 || max_rows > MAX_SQL_MAX_ROWS {
2870 return node_error_response(NodeError::InvalidRequest(format!(
2871 "max_rows must be between 1 and {MAX_SQL_MAX_ROWS}"
2872 )));
2873 }
2874 let result = tokio::task::spawn_blocking(move || {
2875 let _permit = permit;
2876 runtime.query_sql(&request.statement, consistency, max_rows)
2877 })
2878 .await;
2879 match result {
2880 Ok(Ok(response)) => sql_query_http_response(response),
2881 Ok(Err(error)) => node_error_response(error),
2882 Err(error) => client_task_error(error),
2883 }
2884}
2885
2886#[cfg(feature = "sql")]
2887fn sql_query_http_response(response: SqlQueryResponse) -> Response {
2888 match serde_json::to_vec(&response) {
2889 Ok(encoded) if encoded.len() <= MAX_SQL_RESPONSE_BYTES => (
2890 [(axum::http::header::CONTENT_TYPE, "application/json")],
2891 encoded,
2892 )
2893 .into_response(),
2894 Ok(_) => node_error_response(NodeError::InvalidRequest(format!(
2895 "SQL response exceeds {MAX_SQL_RESPONSE_BYTES} bytes"
2896 ))),
2897 Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
2898 }
2899}
2900
2901#[cfg(feature = "graph")]
2902async fn handle_graph_put_document(
2903 State(state): State<NodeRouteState>,
2904 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2905 request: Result<Json<GraphPutDocumentRequest>, JsonRejection>,
2906) -> Response {
2907 let request = match client_json(request) {
2908 Ok(request) => request,
2909 Err(response) => return response,
2910 };
2911 let value = match GraphValueV1::try_from(request.value) {
2912 Ok(value) => value,
2913 Err(error) => return node_error_response(error),
2914 };
2915 let command = match GraphCommandV1::put_document(request.request_id, request.id, value) {
2916 Ok(command) => command,
2917 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2918 };
2919 execute_graph_mutation(state, permit, command).await
2920}
2921
2922#[cfg(feature = "graph")]
2923async fn handle_graph_delete_document(
2924 State(state): State<NodeRouteState>,
2925 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2926 request: Result<Json<GraphDeleteDocumentRequest>, JsonRejection>,
2927) -> Response {
2928 let request = match client_json(request) {
2929 Ok(request) => request,
2930 Err(response) => return response,
2931 };
2932 let command = match GraphCommandV1::delete_document(request.request_id, request.id) {
2933 Ok(command) => command,
2934 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2935 };
2936 execute_graph_mutation(state, permit, command).await
2937}
2938
2939#[cfg(feature = "graph")]
2940async fn execute_graph_mutation(
2941 state: NodeRouteState,
2942 permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2943 command: GraphCommandV1,
2944) -> Response {
2945 let payload = match encode_replicated_graph_command(&command) {
2946 Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
2947 Ok(_) => {
2948 return node_error_response(NodeError::InvalidRequest(format!(
2949 "command exceeds {MAX_COMMAND_BYTES} bytes"
2950 )))
2951 }
2952 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2953 };
2954 coordinate_write(
2955 state,
2956 permit,
2957 command.request_id().to_owned(),
2958 payload,
2959 QueuedOperation::Graph(command),
2960 )
2961 .await
2962}
2963
2964#[cfg(feature = "graph")]
2965async fn handle_graph_get_document(
2966 State(state): State<NodeRouteState>,
2967 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2968 request: Result<Json<GraphGetDocumentRequest>, JsonRejection>,
2969) -> Response {
2970 let request = match client_json(request) {
2971 Ok(request) => request,
2972 Err(response) => return response,
2973 };
2974 let runtime = state.runtime;
2975 let consistency = request
2976 .consistency
2977 .unwrap_or(runtime.config.read_consistency());
2978 let result = tokio::task::spawn_blocking(move || {
2979 let _permit = permit;
2980 runtime.get_graph_document(&request.id, consistency)
2981 })
2982 .await;
2983 match result {
2984 Ok(Ok(response)) => Json(GraphGetDocumentResponse {
2985 value: response.value.map(GraphValueDto::from),
2986 applied_index: response.applied_index,
2987 hash: response.hash,
2988 })
2989 .into_response(),
2990 Ok(Err(error)) => node_error_response(error),
2991 Err(error) => client_task_error(error),
2992 }
2993}
2994
2995#[cfg(feature = "graph")]
2996fn with_graph_client_permit<T>(
2997 permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2998 response_work: impl FnOnce() -> T,
2999) -> T {
3000 let result = response_work();
3001 drop(permit);
3002 result
3003}
3004
3005#[cfg(feature = "graph")]
3006async fn handle_graph_query(
3007 State(state): State<NodeRouteState>,
3008 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3009 request: Result<Json<GraphQueryRequest>, JsonRejection>,
3010) -> Response {
3011 let request = match client_json(request) {
3012 Ok(request) => request,
3013 Err(response) => return response,
3014 };
3015 let parameters = match request
3016 .statement
3017 .parameters
3018 .into_iter()
3019 .map(|(name, value)| GraphParameterValue::try_from(value).map(|value| (name, value)))
3020 .collect::<Result<BTreeMap<_, _>, _>>()
3021 {
3022 Ok(parameters) => parameters,
3023 Err(error) => return node_error_response(error),
3024 };
3025 let runtime = state.runtime;
3026 let consistency = request
3027 .consistency
3028 .unwrap_or(runtime.config.read_consistency());
3029 let max_rows = request.max_rows.unwrap_or(DEFAULT_GRAPH_MAX_ROWS);
3030 if max_rows == 0 || max_rows > MAX_GRAPH_MAX_ROWS {
3031 return node_error_response(NodeError::InvalidRequest(format!(
3032 "max_rows must be between 1 and {MAX_GRAPH_MAX_ROWS}"
3033 )));
3034 }
3035 let result = tokio::task::spawn_blocking(move || {
3036 runtime.query_graph(
3037 &request.statement.cypher,
3038 ¶meters,
3039 consistency,
3040 max_rows,
3041 )
3042 })
3043 .await;
3044 with_graph_client_permit(permit, || match result {
3045 Ok(Ok(result)) => {
3046 let response = GraphQueryResponse::from(result);
3047 match serde_json::to_vec(&response) {
3048 Ok(encoded) if encoded.len() <= MAX_GRAPH_RESPONSE_BYTES => (
3049 [(axum::http::header::CONTENT_TYPE, "application/json")],
3050 encoded,
3051 )
3052 .into_response(),
3053 Ok(_) => node_error_response(NodeError::InvalidRequest(format!(
3054 "graph response exceeds {MAX_GRAPH_RESPONSE_BYTES} bytes"
3055 ))),
3056 Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
3057 }
3058 }
3059 Ok(Err(error)) => node_error_response(error),
3060 Err(error) => client_task_error(error),
3061 })
3062}
3063
3064#[cfg(feature = "kv")]
3065async fn handle_kv_put(
3066 State(state): State<NodeRouteState>,
3067 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3068 request: Result<Json<KvPutRequest>, JsonRejection>,
3069) -> Response {
3070 let request = match client_json(request) {
3071 Ok(request) => request,
3072 Err(response) => return response,
3073 };
3074 let key = match decode_base64("key", &request.key) {
3075 Ok(value) => value,
3076 Err(error) => return node_error_response(error),
3077 };
3078 let value = match decode_base64("value", &request.value) {
3079 Ok(value) => value,
3080 Err(error) => return node_error_response(error),
3081 };
3082 let command = match KvCommandV1::put(request.request_id, key, value) {
3083 Ok(command) => command,
3084 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3085 };
3086 execute_kv_mutation(state, permit, command).await
3087}
3088
3089#[cfg(feature = "kv")]
3090async fn handle_kv_delete(
3091 State(state): State<NodeRouteState>,
3092 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3093 request: Result<Json<KvDeleteRequest>, JsonRejection>,
3094) -> Response {
3095 let request = match client_json(request) {
3096 Ok(request) => request,
3097 Err(response) => return response,
3098 };
3099 let key = match decode_base64("key", &request.key) {
3100 Ok(value) => value,
3101 Err(error) => return node_error_response(error),
3102 };
3103 let command = match KvCommandV1::delete(request.request_id, key) {
3104 Ok(command) => command,
3105 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3106 };
3107 execute_kv_mutation(state, permit, command).await
3108}
3109
3110#[cfg(feature = "kv")]
3111async fn execute_kv_mutation(
3112 state: NodeRouteState,
3113 permit: Arc<tokio::sync::OwnedSemaphorePermit>,
3114 command: KvCommandV1,
3115) -> Response {
3116 let payload = match encode_replicated_kv_command(&command) {
3117 Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
3118 Ok(_) => {
3119 return node_error_response(NodeError::InvalidRequest(format!(
3120 "command exceeds {MAX_COMMAND_BYTES} bytes"
3121 )))
3122 }
3123 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3124 };
3125 coordinate_write(
3126 state,
3127 permit,
3128 command.request_id().to_owned(),
3129 payload,
3130 QueuedOperation::Kv(command),
3131 )
3132 .await
3133}
3134
3135#[cfg(feature = "kv")]
3136async fn handle_kv_get(
3137 State(state): State<NodeRouteState>,
3138 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3139 request: Result<Json<KvGetRequest>, JsonRejection>,
3140) -> Response {
3141 let request = match client_json(request) {
3142 Ok(request) => request,
3143 Err(response) => return response,
3144 };
3145 let key = match decode_base64("key", &request.key) {
3146 Ok(value) => value,
3147 Err(error) => return node_error_response(error),
3148 };
3149 let runtime = state.runtime;
3150 let consistency = request
3151 .consistency
3152 .unwrap_or(runtime.config.read_consistency());
3153 let result = tokio::task::spawn_blocking(move || {
3154 let _permit = permit;
3155 runtime.get_kv(&key, consistency)
3156 })
3157 .await;
3158 match result {
3159 Ok(Ok(response)) => Json(KvGetResponse {
3160 value: response.value.as_deref().map(encode_base64),
3161 applied_index: response.applied_index,
3162 hash: response.hash,
3163 })
3164 .into_response(),
3165 Ok(Err(error)) => node_error_response(error),
3166 Err(error) => client_task_error(error),
3167 }
3168}
3169
3170#[cfg(feature = "kv")]
3171enum DecodedKvScan {
3172 Range {
3173 start: Vec<u8>,
3174 end: Option<Vec<u8>>,
3175 },
3176 Prefix(Vec<u8>),
3177}
3178
3179#[cfg(feature = "kv")]
3180async fn handle_kv_scan(
3181 State(state): State<NodeRouteState>,
3182 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3183 request: Result<Json<KvScanRequest>, JsonRejection>,
3184) -> Response {
3185 let request = match client_json(request) {
3186 Ok(request) => request,
3187 Err(response) => return response,
3188 };
3189 let limit = request
3190 .limit
3191 .unwrap_or(usize::try_from(DEFAULT_KV_SCAN_LIMIT).expect("u32 fits usize"));
3192 if limit == 0 || limit > MAX_KV_SCAN_ROWS {
3193 return node_error_response(NodeError::InvalidRequest(format!(
3194 "limit must be between 1 and {MAX_KV_SCAN_ROWS}"
3195 )));
3196 }
3197 let cursor = match request.cursor {
3198 Some(cursor) => match decode_base64("cursor", &cursor) {
3199 Ok(cursor) => Some(cursor),
3200 Err(error) => return node_error_response(error),
3201 },
3202 None => None,
3203 };
3204 let scan = match (request.prefix, request.start, request.end) {
3205 (Some(prefix), None, None) => match decode_base64("prefix", &prefix) {
3206 Ok(prefix) => DecodedKvScan::Prefix(prefix),
3207 Err(error) => return node_error_response(error),
3208 },
3209 (None, Some(start), end) => {
3210 let start = match decode_base64("start", &start) {
3211 Ok(start) => start,
3212 Err(error) => return node_error_response(error),
3213 };
3214 let end = match end {
3215 Some(end) => match decode_base64("end", &end) {
3216 Ok(end) => Some(end),
3217 Err(error) => return node_error_response(error),
3218 },
3219 None => None,
3220 };
3221 DecodedKvScan::Range { start, end }
3222 }
3223 _ => {
3224 return node_error_response(NodeError::InvalidRequest(
3225 "provide either prefix alone or start with optional end".into(),
3226 ))
3227 }
3228 };
3229 let runtime = state.runtime;
3230 let consistency = request
3231 .consistency
3232 .unwrap_or(runtime.config.read_consistency());
3233 let result = tokio::task::spawn_blocking(move || {
3234 let _permit = permit;
3235 match scan {
3236 DecodedKvScan::Range { start, end } => runtime.scan_kv_range(
3237 &start,
3238 end.as_deref(),
3239 limit,
3240 cursor.as_deref(),
3241 consistency,
3242 ),
3243 DecodedKvScan::Prefix(prefix) => {
3244 runtime.scan_kv_prefix(&prefix, limit, cursor.as_deref(), consistency)
3245 }
3246 }
3247 })
3248 .await;
3249 match result {
3250 Ok(Ok(result)) => {
3251 let response = KvScanResponse {
3252 entries: result
3253 .rows()
3254 .iter()
3255 .map(|row| KvScanEntryDto {
3256 key: encode_base64(row.key()),
3257 value: encode_base64(row.value()),
3258 })
3259 .collect(),
3260 next_cursor: result.next_cursor().map(encode_base64),
3261 applied_index: result.tip().applied_index(),
3262 hash: result.tip().applied_hash(),
3263 };
3264 match serde_json::to_vec(&response) {
3265 Ok(encoded) if encoded.len() <= MAX_KV_SCAN_RESPONSE_BYTES => {
3266 Json(response).into_response()
3267 }
3268 Ok(_) => node_error_response(NodeError::ResourceExhausted(format!(
3269 "KV scan response exceeds {MAX_KV_SCAN_RESPONSE_BYTES} bytes"
3270 ))),
3271 Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
3272 }
3273 }
3274 Ok(Err(error)) => node_error_response(error),
3275 Err(error) => client_task_error(error),
3276 }
3277}
3278
3279#[cfg(feature = "sql")]
3280async fn handle_read(
3281 State(state): State<NodeRouteState>,
3282 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3283 request: Result<Json<ReadRequest>, JsonRejection>,
3284) -> Response {
3285 let request = match client_json(request) {
3286 Ok(request) => request,
3287 Err(response) => return response,
3288 };
3289 let runtime = state.runtime;
3290 let consistency = request
3291 .consistency
3292 .unwrap_or(runtime.config.read_consistency());
3293 let result = tokio::task::spawn_blocking(move || {
3294 let _permit = permit;
3295 runtime.read(&request.key, consistency)
3296 })
3297 .await;
3298 match result {
3299 Ok(Ok(response)) => Json(response).into_response(),
3300 Ok(Err(error)) => node_error_response(error),
3301 Err(error) => client_task_error(error),
3302 }
3303}
3304
3305async fn peer_gate(
3306 State(state): State<PeerGateState>,
3307 mut request: Request,
3308 next: Next,
3309) -> Response {
3310 if !recovery_generation_matches(request.headers(), state.recovery_generation) {
3311 return StatusCode::UNAUTHORIZED.into_response();
3312 }
3313 let Some(authenticated_peer) =
3314 authenticated_peer(request.headers(), &state.peers, state.protocol_version)
3315 else {
3316 return StatusCode::UNAUTHORIZED.into_response();
3317 };
3318 let permit = match state.slots.try_acquire_owned() {
3319 Ok(permit) => Arc::new(permit),
3320 Err(_) => return StatusCode::TOO_MANY_REQUESTS.into_response(),
3321 };
3322 request.extensions_mut().insert(permit);
3323 request
3324 .extensions_mut()
3325 .insert(AuthenticatedPeer(authenticated_peer));
3326 next.run(request).await
3327}
3328
3329async fn client_gate(
3330 State(state): State<ClientGateState>,
3331 mut request: Request,
3332 next: Next,
3333) -> Response {
3334 if !client_authenticated(request.headers(), state.runtime.config.client_token()) {
3335 return client_error_response(
3336 StatusCode::UNAUTHORIZED,
3337 "unauthorized",
3338 false,
3339 "client authentication failed",
3340 None,
3341 );
3342 }
3343 if let Some(response) = runtime_readiness_response(state.runtime.as_ref()) {
3344 return response;
3345 }
3346 if client_write_path(request.uri().path())
3347 && state
3348 .coordinator
3349 .as_ref()
3350 .is_some_and(|coordinator| coordinator.write_allowed().is_err())
3351 {
3352 return client_error_response(
3353 StatusCode::SERVICE_UNAVAILABLE,
3354 "writes_unavailable",
3355 true,
3356 "writes are temporarily unavailable",
3357 None,
3358 );
3359 }
3360 let permit = match state.slots.try_acquire_owned() {
3361 Ok(permit) => Arc::new(permit),
3362 Err(_) => {
3363 return client_error_response(
3364 StatusCode::TOO_MANY_REQUESTS,
3365 "overloaded",
3366 true,
3367 "client request capacity is exhausted",
3368 None,
3369 )
3370 }
3371 };
3372 request.extensions_mut().insert(permit);
3373 next.run(request).await
3374}
3375
3376fn client_write_path(path: &str) -> bool {
3377 #[cfg(feature = "sql")]
3378 if matches!(path, WRITE_PATH | SQL_EXECUTE_PATH) {
3379 return true;
3380 }
3381 #[cfg(feature = "graph")]
3382 if matches!(path, GRAPH_PUT_DOCUMENT_PATH | GRAPH_DELETE_DOCUMENT_PATH) {
3383 return true;
3384 }
3385 #[cfg(feature = "kv")]
3386 if matches!(path, KV_PUT_PATH | KV_DELETE_PATH) {
3387 return true;
3388 }
3389 false
3390}
3391
3392async fn handle_livez() -> StatusCode {
3393 StatusCode::OK
3394}
3395
3396async fn handle_readyz(State(state): State<NodeRouteState>) -> StatusCode {
3397 if state.runtime.is_ready()
3398 && state
3399 .runtime
3400 .configuration_state()
3401 .is_ok_and(|configuration| configuration.is_active())
3402 && state
3403 .coordinator
3404 .as_ref()
3405 .is_none_or(|coordinator| coordinator.health() == DurabilityHealth::Available)
3406 {
3407 StatusCode::OK
3408 } else {
3409 StatusCode::SERVICE_UNAVAILABLE
3410 }
3411}
3412
3413fn next_sync_flush_retry(current: Duration) -> Duration {
3414 current.saturating_mul(2).min(SYNC_FLUSH_RETRY_MAX)
3415}
3416
3417pub async fn confirm_write_durability(
3422 runtime: &NodeRuntime,
3423 coordinator: Option<&CheckpointCoordinator>,
3424 index: LogIndex,
3425) -> Result<(), DurabilityError> {
3426 let Some(coordinator) = coordinator else {
3427 return Ok(());
3428 };
3429 coordinator.note_committed(index);
3430 if !matches!(coordinator.mode(), DurabilityMode::Sync) {
3431 return Ok(());
3432 }
3433
3434 let mut retry_delay = SYNC_FLUSH_RETRY_INITIAL;
3435 loop {
3436 if runtime.operation_cancelled.load(Ordering::Acquire) {
3437 return Err(DurabilityError::Unavailable);
3438 }
3439 match coordinator.flush_runtime(runtime, index).await {
3440 Ok(tip) if tip.index() >= index => return Ok(()),
3441 Ok(tip) => {
3442 return Err(DurabilityError::LocalLogGap {
3443 expected: index,
3444 actual: Some(tip.index()),
3445 })
3446 }
3447 Err(DurabilityError::Archive(_) | DurabilityError::Io(_)) => {
3448 let cancelled = runtime.operation_cancelled_notify.notified();
3449 tokio::pin!(cancelled);
3450 cancelled.as_mut().enable();
3451 if runtime.operation_cancelled.load(Ordering::Acquire) {
3452 return Err(DurabilityError::Unavailable);
3453 }
3454 tokio::select! {
3455 () = tokio::time::sleep(retry_delay) => {}
3456 () = &mut cancelled => return Err(DurabilityError::Unavailable),
3457 }
3458 retry_delay = next_sync_flush_retry(retry_delay);
3459 }
3460 Err(error) => return Err(error),
3461 }
3462 }
3463}
3464
3465fn authenticated_peer(
3466 headers: &HeaderMap,
3467 peers: &[PeerConfig],
3468 protocol_version: &str,
3469) -> Option<String> {
3470 if header_text(headers, VERSION_HEADER) != Some(protocol_version) {
3471 return None;
3472 }
3473 let node_id = header_text(headers, NODE_ID_HEADER)?;
3474 let token = bearer_token(headers)?;
3475 peer_credentials_authenticated(node_id, token, peers).then(|| node_id.to_owned())
3476}
3477
3478fn peer_credentials_authenticated(node_id: &str, token: &str, peers: &[PeerConfig]) -> bool {
3479 peers
3480 .iter()
3481 .find(|peer| peer.node_id == node_id)
3482 .is_some_and(|peer| secrets_equal(peer.token.as_bytes(), token.as_bytes()))
3483}
3484
3485fn recovery_generation_matches(headers: &HeaderMap, expected: u64) -> bool {
3486 let expected = expected.to_string();
3487 header_text(headers, RECOVERY_GENERATION_HEADER) == Some(expected.as_str())
3488}
3489
3490fn client_authenticated(headers: &HeaderMap, expected_token: &str) -> bool {
3491 !expected_token.is_empty()
3492 && version_matches(headers)
3493 && bearer_token(headers)
3494 .is_some_and(|token| secrets_equal(expected_token.as_bytes(), token.as_bytes()))
3495}
3496
3497fn version_matches(headers: &HeaderMap) -> bool {
3498 header_text(headers, VERSION_HEADER) == Some(PROTOCOL_VERSION)
3499}
3500
3501fn header_text<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
3502 headers.get(name)?.to_str().ok()
3503}
3504
3505fn bearer_token(headers: &HeaderMap) -> Option<&str> {
3506 header_text(headers, "authorization")?.strip_prefix("Bearer ")
3507}
3508
3509fn secrets_equal(left: &[u8], right: &[u8]) -> bool {
3510 if left.len() != right.len() {
3511 return false;
3512 }
3513 left.iter()
3514 .zip(right)
3515 .fold(0_u8, |difference, (left, right)| {
3516 difference | (left ^ right)
3517 })
3518 == 0
3519}
3520
3521fn node_error_response(error: NodeError) -> Response {
3522 let (status, statement_index) = match &error {
3523 NodeError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, None),
3524 #[cfg(feature = "sql")]
3525 NodeError::InvalidSqlStatement {
3526 statement_index, ..
3527 } => (StatusCode::BAD_REQUEST, Some(*statement_index)),
3528 #[cfg(feature = "sql")]
3529 NodeError::RequestConflict(_) => (StatusCode::CONFLICT, None),
3530 NodeError::PreconditionFailed(_) => (StatusCode::CONFLICT, None),
3531 NodeError::SnapshotRequired(_)
3532 | NodeError::Unavailable(_)
3533 | NodeError::ResourceExhausted(_)
3534 | NodeError::ConfigurationTransition { .. }
3535 | NodeError::Contention(_)
3536 | NodeError::WinnerLimitExceeded => (StatusCode::SERVICE_UNAVAILABLE, None),
3537 NodeError::DataRootLocked(_)
3538 | NodeError::UnsupportedAckMode(_)
3539 | NodeError::ExecutionProfileMismatch { .. }
3540 | NodeError::Storage(_)
3541 | NodeError::Reconciliation(_)
3542 | NodeError::Invariant(_)
3543 | NodeError::Fatal(_) => (StatusCode::INTERNAL_SERVER_ERROR, None),
3544 };
3545 let classification = error.classification();
3546 if !matches!(&error, NodeError::Fatal(_)) {
3547 eprintln!(
3548 "node request failed (code={}, retryable={}): {}",
3549 classification.code(),
3550 classification.retryable(),
3551 escaped_error_detail(&error)
3552 );
3553 }
3554 client_error_response(
3555 status,
3556 classification.code(),
3557 classification.retryable(),
3558 node_error_message(status),
3559 statement_index,
3560 )
3561}
3562
3563fn node_error_message(status: StatusCode) -> &'static str {
3564 match status {
3565 StatusCode::BAD_REQUEST => "request could not be processed",
3566 StatusCode::CONFLICT => "request conflicts with current state",
3567 StatusCode::SERVICE_UNAVAILABLE => "service is temporarily unavailable",
3568 _ => "internal server error",
3569 }
3570}
3571
3572const MAX_ESCAPED_ERROR_DETAIL_BYTES: usize = 4 * 1024;
3573const ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER: &str = "...[truncated]";
3574
3575fn escaped_error_detail(error: &dyn fmt::Display) -> String {
3576 let detail = error.to_string();
3577 let mut escaped = String::with_capacity(detail.len().min(MAX_ESCAPED_ERROR_DETAIL_BYTES));
3578 for character in detail.chars() {
3579 let character_start = escaped.len();
3580 for escaped_character in character.escape_default() {
3581 if escaped.len()
3582 + escaped_character.len_utf8()
3583 + ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER.len()
3584 > MAX_ESCAPED_ERROR_DETAIL_BYTES
3585 {
3586 escaped.truncate(character_start);
3587 escaped.push_str(ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER);
3588 return escaped;
3589 }
3590 escaped.push(escaped_character);
3591 }
3592 }
3593 escaped
3594}
3595
3596#[allow(clippy::result_large_err)]
3597fn client_json<T>(request: Result<Json<T>, JsonRejection>) -> Result<T, Response> {
3598 request.map(|Json(request)| request).map_err(|rejection| {
3599 let status = rejection.status();
3600 eprintln!("invalid JSON request: {}", escaped_error_detail(&rejection));
3601 client_json_error_response(status)
3602 })
3603}
3604
3605fn client_json_error_response(status: StatusCode) -> Response {
3606 let code = if status == StatusCode::PAYLOAD_TOO_LARGE {
3607 "payload_too_large"
3608 } else {
3609 "invalid_json"
3610 };
3611 client_error_response(status, code, false, "request body is invalid", None)
3612}
3613
3614fn client_task_error(error: tokio::task::JoinError) -> Response {
3615 eprintln!("request task failed: {}", escaped_error_detail(&error));
3616 client_error_response(
3617 StatusCode::INTERNAL_SERVER_ERROR,
3618 "task_failed",
3619 false,
3620 "request task failed",
3621 None,
3622 )
3623}
3624
3625fn client_error_response(
3626 status: StatusCode,
3627 code: impl Into<String>,
3628 retryable: bool,
3629 message: impl Into<String>,
3630 statement_index: Option<usize>,
3631) -> Response {
3632 (
3633 status,
3634 Json(ClientErrorResponse {
3635 code: code.into(),
3636 retryable,
3637 message: message.into(),
3638 statement_index,
3639 }),
3640 )
3641 .into_response()
3642}
3643
3644pub fn install_successor_recorder(
3645 recorder: &RecorderFileStore,
3646 next_config_id: u64,
3647 membership: Membership,
3648 stop: &StopInformation,
3649) -> Result<rhiza_quepaxa::ConfigurationState, NodeError> {
3650 if stop.entry.config_id.checked_add(1) != Some(next_config_id) {
3651 return Err(NodeError::PreconditionFailed(
3652 "successor identity does not match the Stop proof".into(),
3653 ));
3654 }
3655 recorder
3656 .install_successor_from_proof(membership, &stop.proof)
3657 .map_err(|error| NodeError::Reconciliation(error.to_string()))
3658}
3659
3660pub fn recover_successor_recorder_after_checkpoint(
3661 recorder: &RecorderFileStore,
3662 config: &NodeConfig,
3663 next_config_id: u64,
3664 membership: Membership,
3665 stop: &StopInformation,
3666) -> Result<rhiza_quepaxa::ConfigurationState, NodeError> {
3667 let installed = install_successor_recorder(recorder, next_config_id, membership.clone(), stop)?;
3668 let log = FileLogStore::open_with_configuration(
3669 config.data_dir.join("consensus/log"),
3670 &config.cluster_id,
3671 config.epoch,
3672 config.log_initial_configuration.clone(),
3673 )
3674 .map_err(|error| NodeError::Storage(error.to_string()))?;
3675 let recovered_configuration = log
3676 .configuration_state()
3677 .map_err(|error| NodeError::Storage(error.to_string()))?;
3678 if !recovered_configuration.is_active() {
3679 return Ok(installed);
3680 }
3681 if recovered_configuration.config_id() != next_config_id
3682 || recovered_configuration.digest() != membership.digest()
3683 {
3684 return Err(NodeError::Reconciliation(
3685 "recovered successor qlog configuration does not match the target bundle".into(),
3686 ));
3687 }
3688 let tip = log
3689 .logical_state()
3690 .map_err(|error| NodeError::Storage(error.to_string()))?
3691 .tip
3692 .ok_or_else(|| NodeError::Reconciliation("recovered successor qlog is empty".into()))?;
3693 recorder
3694 .recover_successor_activation_from_checkpoint(
3695 stop.entry.index,
3696 stop.entry.hash,
3697 tip.index(),
3698 tip.hash(),
3699 )
3700 .map_err(|error| NodeError::Reconciliation(error.to_string()))
3701}
3702
3703fn recorder_error_status(error: &rhiza_quepaxa::Error) -> StatusCode {
3704 match error {
3705 rhiza_quepaxa::Error::NoQuorum
3706 | rhiza_quepaxa::Error::CommandUnavailable
3707 | rhiza_quepaxa::Error::Io(_)
3708 | rhiza_quepaxa::Error::RecorderRootLocked(_) => StatusCode::SERVICE_UNAVAILABLE,
3709 rhiza_quepaxa::Error::Rejected(_) => StatusCode::CONFLICT,
3710 _ => StatusCode::INTERNAL_SERVER_ERROR,
3711 }
3712}
3713
3714fn fetch_log_error_status(error: &FetchLogError) -> StatusCode {
3715 match error {
3716 FetchLogError::InvalidRequest { .. } => StatusCode::BAD_REQUEST,
3717 FetchLogError::SnapshotRequired { .. } | FetchLogError::Gap { .. } => StatusCode::CONFLICT,
3718 FetchLogError::Decode { .. } | FetchLogError::Transport { .. } => {
3719 StatusCode::SERVICE_UNAVAILABLE
3720 }
3721 FetchLogError::InvalidAnchor { .. }
3722 | FetchLogError::InvalidEntry { .. }
3723 | FetchLogError::ForeignIdentity { .. } => StatusCode::INTERNAL_SERVER_ERROR,
3724 }
3725}
3726
3727fn runtime_readiness_response(runtime: &NodeRuntime) -> Option<Response> {
3728 if runtime.is_fatal() {
3729 Some(client_error_response(
3730 StatusCode::INTERNAL_SERVER_ERROR,
3731 "fatal",
3732 false,
3733 "node is fatally unavailable",
3734 None,
3735 ))
3736 } else if !runtime.is_ready() {
3737 Some(client_error_response(
3738 StatusCode::SERVICE_UNAVAILABLE,
3739 "unavailable",
3740 true,
3741 "node is not ready",
3742 None,
3743 ))
3744 } else {
3745 None
3746 }
3747}
3748
3749#[derive(Debug, serde::Deserialize, serde::Serialize)]
3750#[serde(tag = "status", content = "body")]
3751enum FetchLogHttpResponse {
3752 Fetched(FetchLogResponse),
3753 Failed(FetchLogError),
3754}
3755
3756pub fn catch_up_missing_entries<P: LogPeer + ?Sized>(
3757 local_last_index: LogIndex,
3758 local_last_hash: LogHash,
3759 cluster_id: &str,
3760 epoch: u64,
3761 config_id: u64,
3762 peer: &P,
3763 max_entries: u32,
3764) -> Result<Vec<LogEntry>, FetchLogError> {
3765 if max_entries == 0 {
3766 return Ok(Vec::new());
3767 }
3768 if max_entries > MAX_FETCH_ENTRIES {
3769 return Err(FetchLogError::InvalidRequest {
3770 message: format!("max_entries exceeds {MAX_FETCH_ENTRIES}"),
3771 });
3772 }
3773 if cluster_id.is_empty() {
3774 return Err(FetchLogError::InvalidRequest {
3775 message: "cluster_id must not be empty".into(),
3776 });
3777 }
3778 let from_index =
3779 local_last_index
3780 .checked_add(1)
3781 .ok_or_else(|| FetchLogError::InvalidRequest {
3782 message: "local qlog index is exhausted".into(),
3783 })?;
3784 let response = peer.fetch_log(FetchLogRequest {
3785 from_index,
3786 max_entries,
3787 })?;
3788 if response.entries.len() > max_entries as usize {
3789 return Err(FetchLogError::InvalidRequest {
3790 message: "peer returned more entries than requested".into(),
3791 });
3792 }
3793 if response.last_index < local_last_index {
3794 return Err(FetchLogError::Gap {
3795 expected: local_last_index,
3796 actual: Some(response.last_index),
3797 });
3798 }
3799 if response.entries.is_empty() && response.last_index >= from_index {
3800 return Err(FetchLogError::Gap {
3801 expected: from_index,
3802 actual: None,
3803 });
3804 }
3805 validate_fetched_entries(
3806 from_index,
3807 local_last_hash,
3808 cluster_id,
3809 epoch,
3810 config_id,
3811 response.entries,
3812 )
3813}
3814
3815fn validate_fetched_entries(
3816 from_index: LogIndex,
3817 local_last_hash: LogHash,
3818 cluster_id: &str,
3819 epoch: u64,
3820 config_id: u64,
3821 entries: Vec<LogEntry>,
3822) -> Result<Vec<LogEntry>, FetchLogError> {
3823 validate_fetched_entries_with_configuration(
3824 from_index,
3825 local_last_hash,
3826 cluster_id,
3827 epoch,
3828 ConfigurationState::active(config_id, LogHash::ZERO),
3829 entries,
3830 )
3831}
3832
3833fn validate_fetched_entries_with_configuration(
3834 from_index: LogIndex,
3835 local_last_hash: LogHash,
3836 cluster_id: &str,
3837 epoch: u64,
3838 mut configuration_state: ConfigurationState,
3839 entries: Vec<LogEntry>,
3840) -> Result<Vec<LogEntry>, FetchLogError> {
3841 let mut expected = from_index;
3842 let mut previous_hash = local_last_hash;
3843 for entry in &entries {
3844 if entry.index != expected {
3845 return Err(FetchLogError::Gap {
3846 expected,
3847 actual: Some(entry.index),
3848 });
3849 }
3850 if entry.cluster_id != cluster_id || entry.epoch != epoch {
3851 return Err(FetchLogError::ForeignIdentity { index: entry.index });
3852 }
3853 if entry.prev_hash != previous_hash {
3854 return Err(FetchLogError::InvalidAnchor {
3855 expected: previous_hash,
3856 actual: entry.prev_hash,
3857 });
3858 }
3859 if entry.recompute_hash() != entry.hash {
3860 return Err(FetchLogError::InvalidEntry {
3861 index: entry.index,
3862 message: "hash does not match entry contents".into(),
3863 });
3864 }
3865 validate_entry_shape(entry).map_err(|message| FetchLogError::InvalidEntry {
3866 index: entry.index,
3867 message,
3868 })?;
3869 configuration_state = configuration_state.validate_entry(entry).map_err(|error| {
3870 FetchLogError::InvalidEntry {
3871 index: entry.index,
3872 message: error.to_string(),
3873 }
3874 })?;
3875 expected = expected
3876 .checked_add(1)
3877 .ok_or_else(|| FetchLogError::InvalidEntry {
3878 index: entry.index,
3879 message: "qlog index is exhausted".into(),
3880 })?;
3881 previous_hash = entry.hash;
3882 }
3883 Ok(entries)
3884}
3885
3886#[derive(Clone, Eq, PartialEq)]
3887pub struct PeerConfig {
3888 node_id: String,
3889 base_url: String,
3890 log_base_url: String,
3891 token: String,
3892}
3893
3894impl fmt::Debug for PeerConfig {
3895 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3896 f.debug_struct("PeerConfig")
3897 .field("node_id", &self.node_id)
3898 .field("base_url", &self.base_url)
3899 .field("log_base_url", &self.log_base_url)
3900 .field("token", &"[redacted]")
3901 .finish()
3902 }
3903}
3904
3905impl PeerConfig {
3906 pub fn new(
3907 node_id: impl Into<String>,
3908 base_url: impl Into<String>,
3909 token: impl Into<String>,
3910 ) -> Result<Self, ConfigError> {
3911 let base_url = base_url.into();
3912 Self::new_with_log_url(node_id, base_url.clone(), base_url, token)
3913 }
3914
3915 pub fn new_with_log_url(
3916 node_id: impl Into<String>,
3917 base_url: impl Into<String>,
3918 log_base_url: impl Into<String>,
3919 token: impl Into<String>,
3920 ) -> Result<Self, ConfigError> {
3921 let node_id = node_id.into();
3922 if !valid_nonblank_header_value(&node_id) {
3923 return Err(ConfigError::EmptyPeerNodeId);
3924 }
3925 let base_url = validate_peer_base_url(base_url.into())?;
3926 let log_base_url = validate_peer_base_url(log_base_url.into())?;
3927 let token = token.into();
3928 if !valid_auth_token(&token) {
3929 return Err(ConfigError::EmptyPeerToken);
3930 }
3931 Ok(Self {
3932 node_id,
3933 base_url,
3934 log_base_url,
3935 token,
3936 })
3937 }
3938
3939 pub fn node_id(&self) -> &str {
3940 &self.node_id
3941 }
3942
3943 pub fn base_url(&self) -> &str {
3944 &self.base_url
3945 }
3946
3947 pub fn log_base_url(&self) -> &str {
3948 &self.log_base_url
3949 }
3950
3951 pub fn token(&self) -> &str {
3952 &self.token
3953 }
3954}
3955
3956fn validate_peer_base_url(url: String) -> Result<String, ConfigError> {
3957 let url = url.trim_end_matches('/').to_string();
3958 if url.trim().is_empty() {
3959 return Err(ConfigError::EmptyPeerBaseUrl);
3960 }
3961 let parsed =
3962 reqwest::Url::parse(&url).map_err(|_| ConfigError::InvalidPeerBaseUrl(url.clone()))?;
3963 if !matches!(parsed.scheme(), "http" | "https")
3964 || parsed.host_str().is_none()
3965 || !parsed.username().is_empty()
3966 || parsed.password().is_some()
3967 || parsed.path() != "/"
3968 || parsed.query().is_some()
3969 || parsed.fragment().is_some()
3970 {
3971 return Err(ConfigError::InvalidPeerBaseUrl(url));
3972 }
3973 Ok(url)
3974}
3975
3976pub(crate) fn valid_nonblank_header_value(value: &str) -> bool {
3977 !value.trim().is_empty()
3978 && axum::http::HeaderValue::try_from(value).is_ok_and(|value| value.to_str().is_ok())
3979}
3980
3981pub(crate) fn valid_auth_token(value: &str) -> bool {
3982 valid_nonblank_header_value(value) && !value.chars().any(char::is_whitespace)
3983}
3984
3985#[derive(Clone, Eq, PartialEq)]
3986pub struct NodeConfig {
3987 cluster_id_source: String,
3988 logical_cluster_id: String,
3989 cluster_id: String,
3990 node_id: String,
3991 data_dir: PathBuf,
3992 epoch: u64,
3993 membership: Membership,
3994 log_initial_configuration: ConfigurationState,
3995 configuration_state: ConfigurationState,
3996 predecessor_stop_entry: Option<LogEntry>,
3997 recovery_generation: u64,
3998 peers: Vec<PeerConfig>,
3999 client_token: String,
4000 read_consistency: ReadConsistency,
4001 ack_mode: AckMode,
4002 writer_batch_max: usize,
4003 writer_batch_window: Duration,
4004 execution_profile: ExecutionProfile,
4005 #[cfg(feature = "sql")]
4006 sql_write_profiler: Option<SqlWriteProfiler>,
4007 #[cfg(feature = "sql")]
4008 sql_group_commit_queue_capacity: usize,
4009}
4010
4011impl fmt::Debug for NodeConfig {
4012 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4013 let mut debug = f.debug_struct("NodeConfig");
4014 debug
4015 .field("cluster_id_source", &self.cluster_id_source)
4016 .field("logical_cluster_id", &self.logical_cluster_id)
4017 .field("cluster_id", &self.cluster_id)
4018 .field("node_id", &self.node_id)
4019 .field("data_dir", &self.data_dir)
4020 .field("epoch", &self.epoch)
4021 .field("membership", &self.membership.members())
4022 .field("log_initial_configuration", &self.log_initial_configuration)
4023 .field("configuration_state", &self.configuration_state)
4024 .field(
4025 "predecessor_stop_entry",
4026 &self
4027 .predecessor_stop_entry
4028 .as_ref()
4029 .map(|entry| (entry.index, entry.hash)),
4030 )
4031 .field("recovery_generation", &self.recovery_generation)
4032 .field("peers", &self.peers)
4033 .field("client_token", &"[redacted]")
4034 .field("read_consistency", &self.read_consistency)
4035 .field("ack_mode", &self.ack_mode)
4036 .field("writer_batch_max", &self.writer_batch_max)
4037 .field("writer_batch_window", &self.writer_batch_window)
4038 .field("execution_profile", &self.execution_profile);
4039 #[cfg(feature = "sql")]
4040 debug.field(
4041 "sql_write_profiler",
4042 &self.sql_write_profiler.as_ref().map(|_| "installed"),
4043 );
4044 #[cfg(feature = "sql")]
4045 debug.field(
4046 "sql_group_commit_queue_capacity",
4047 &self.sql_group_commit_queue_capacity,
4048 );
4049 debug.finish()
4050 }
4051}
4052
4053impl NodeConfig {
4054 pub fn new<P>(
4055 cluster_id: impl Into<String>,
4056 node_id: impl Into<String>,
4057 data_dir: PathBuf,
4058 epoch: u64,
4059 config_id: u64,
4060 peers: P,
4061 client_token: impl Into<String>,
4062 ) -> Result<Self, ConfigError>
4063 where
4064 P: Into<Vec<PeerConfig>>,
4065 {
4066 let peers = peers.into();
4067 let membership = membership_from_peers(&peers)?;
4068 let configuration_state = ConfigurationState::active(config_id, membership.digest());
4069 Self::new_with_configuration(
4070 cluster_id,
4071 node_id,
4072 data_dir,
4073 epoch,
4074 membership,
4075 configuration_state,
4076 peers,
4077 client_token,
4078 )
4079 }
4080
4081 pub fn new_embedded<I, S>(
4082 cluster_id: impl Into<String>,
4083 node_id: impl Into<String>,
4084 data_dir: PathBuf,
4085 epoch: u64,
4086 config_id: u64,
4087 members: I,
4088 ) -> Result<Self, ConfigError>
4089 where
4090 I: IntoIterator<Item = S>,
4091 S: Into<String>,
4092 {
4093 let cluster_id = cluster_id.into();
4094 let node_id = node_id.into();
4095 validate_node_identity(&cluster_id, &node_id, &data_dir, epoch, config_id)?;
4096 let membership = membership_from_node_ids(members.into_iter().map(Into::into).collect())?;
4097 if !membership.contains(&node_id) {
4098 return Err(ConfigError::LocalNodeMissing);
4099 }
4100 let configuration_state = ConfigurationState::active(config_id, membership.digest());
4101 Self::from_validated_parts(
4102 cluster_id,
4103 node_id,
4104 data_dir,
4105 epoch,
4106 membership,
4107 configuration_state,
4108 Vec::new(),
4109 String::new(),
4110 )
4111 }
4112
4113 #[allow(clippy::too_many_arguments)]
4114 pub fn new_with_configuration<P>(
4115 cluster_id: impl Into<String>,
4116 node_id: impl Into<String>,
4117 data_dir: PathBuf,
4118 epoch: u64,
4119 membership: Membership,
4120 configuration_state: ConfigurationState,
4121 peers: P,
4122 client_token: impl Into<String>,
4123 ) -> Result<Self, ConfigError>
4124 where
4125 P: Into<Vec<PeerConfig>>,
4126 {
4127 let cluster_id = cluster_id.into();
4128 let node_id = node_id.into();
4129 let client_token = client_token.into();
4130 let peers = peers.into();
4131
4132 validate_node_identity(
4133 &cluster_id,
4134 &node_id,
4135 &data_dir,
4136 epoch,
4137 configuration_state.config_id(),
4138 )?;
4139 if !(3..=7).contains(&peers.len()) {
4140 return Err(ConfigError::InvalidPeerCount(peers.len()));
4141 }
4142 let mut peer_ids = HashSet::with_capacity(peers.len());
4143 let mut peer_tokens = HashSet::with_capacity(peers.len());
4144 for peer in &peers {
4145 if !peer_ids.insert(peer.node_id.clone()) {
4146 return Err(ConfigError::DuplicatePeerNodeId(peer.node_id.clone()));
4147 }
4148 if !peer_tokens.insert(peer.token.as_str()) {
4149 return Err(ConfigError::DuplicatePeerToken);
4150 }
4151 }
4152 if !peer_ids.contains(&node_id) {
4153 return Err(ConfigError::LocalNodeMissing);
4154 }
4155 if peer_ids.len() != membership.members().len()
4156 || membership
4157 .members()
4158 .iter()
4159 .any(|member| !peer_ids.contains(member))
4160 {
4161 return Err(ConfigError::PeerMembershipMismatch);
4162 }
4163 if configuration_state.is_active() && configuration_state.digest() != membership.digest() {
4164 return Err(ConfigError::PeerMembershipMismatch);
4165 }
4166 if !valid_auth_token(&client_token) {
4167 return Err(ConfigError::EmptyClientToken);
4168 }
4169 if peer_tokens.contains(client_token.as_str()) {
4170 return Err(ConfigError::ClientTokenConflictsWithPeer);
4171 }
4172
4173 Self::from_validated_parts(
4174 cluster_id,
4175 node_id,
4176 data_dir,
4177 epoch,
4178 membership,
4179 configuration_state,
4180 peers,
4181 client_token,
4182 )
4183 }
4184
4185 #[allow(clippy::too_many_arguments)]
4186 fn from_validated_parts(
4187 cluster_id: String,
4188 node_id: String,
4189 data_dir: PathBuf,
4190 epoch: u64,
4191 membership: Membership,
4192 configuration_state: ConfigurationState,
4193 peers: Vec<PeerConfig>,
4194 client_token: String,
4195 ) -> Result<Self, ConfigError> {
4196 let log_initial_configuration = ConfigurationState::active(
4197 configuration_state.config_id(),
4198 configuration_state.digest(),
4199 );
4200 let execution_profile =
4201 canonical_cluster_profile(&cluster_id).unwrap_or(ExecutionProfile::Sqlite);
4202 let logical_cluster_id = ["rhiza:sql:", "rhiza:graph:", "rhiza:kv:"]
4203 .into_iter()
4204 .find_map(|prefix| cluster_id.strip_prefix(prefix))
4205 .unwrap_or(&cluster_id)
4206 .to_owned();
4207 let effective_cluster_id = effective_cluster_id(execution_profile, &cluster_id)?;
4208 Ok(Self {
4209 cluster_id_source: cluster_id,
4210 cluster_id: effective_cluster_id,
4211 logical_cluster_id,
4212 node_id,
4213 data_dir,
4214 epoch,
4215 membership,
4216 log_initial_configuration,
4217 configuration_state,
4218 predecessor_stop_entry: None,
4219 recovery_generation: 1,
4220 peers,
4221 client_token,
4222 read_consistency: ReadConsistency::ReadBarrier,
4223 ack_mode: AckMode::HaFirst,
4224 writer_batch_max: DEFAULT_WRITER_BATCH_MAX,
4225 writer_batch_window: DEFAULT_WRITER_BATCH_WINDOW,
4226 execution_profile,
4227 #[cfg(feature = "sql")]
4228 sql_write_profiler: None,
4229 #[cfg(feature = "sql")]
4230 sql_group_commit_queue_capacity: DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY,
4231 })
4232 }
4233
4234 pub fn with_execution_profile(
4235 mut self,
4236 execution_profile: ExecutionProfile,
4237 ) -> Result<Self, ConfigError> {
4238 self.cluster_id = effective_cluster_id(execution_profile, &self.cluster_id_source)?;
4239 self.execution_profile = execution_profile;
4240 Ok(self)
4241 }
4242
4243 pub fn with_read_consistency(mut self, read_consistency: ReadConsistency) -> Self {
4244 self.read_consistency = read_consistency;
4245 self
4246 }
4247
4248 #[cfg(feature = "sql")]
4249 pub fn with_sql_write_profiler(mut self, profiler: SqlWriteProfiler) -> Self {
4250 self.sql_write_profiler = Some(profiler);
4251 self
4252 }
4253
4254 #[cfg(feature = "sql")]
4255 pub fn with_sql_group_commit_queue_capacity(
4256 mut self,
4257 capacity: usize,
4258 ) -> Result<Self, ConfigError> {
4259 if !(1..=MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY).contains(&capacity) {
4260 return Err(ConfigError::InvalidSqlGroupCommitQueueCapacity(capacity));
4261 }
4262 self.sql_group_commit_queue_capacity = capacity;
4263 Ok(self)
4264 }
4265
4266 pub fn with_ack_mode(mut self, ack_mode: AckMode) -> Self {
4267 self.ack_mode = ack_mode;
4268 self
4269 }
4270
4271 pub fn with_writer_batching(
4272 mut self,
4273 max: usize,
4274 window: Duration,
4275 ) -> Result<Self, ConfigError> {
4276 if max == 0 || max > MAX_WRITE_BATCH_MEMBERS {
4277 return Err(ConfigError::InvalidWriterBatchMax(max));
4278 }
4279 if window.is_zero() || window >= CLIENT_WRITE_WAIT_TIMEOUT {
4280 return Err(ConfigError::InvalidWriterBatchWindow);
4281 }
4282 self.writer_batch_max = max;
4283 self.writer_batch_window = window;
4284 Ok(self)
4285 }
4286
4287 pub fn with_log_initial_configuration(mut self, configuration: ConfigurationState) -> Self {
4288 self.log_initial_configuration = configuration;
4289 self
4290 }
4291
4292 pub fn with_predecessor_stop_entry(mut self, entry: LogEntry) -> Self {
4293 self.predecessor_stop_entry = Some(entry);
4294 self
4295 }
4296
4297 pub fn bind_predecessor_stop(
4299 mut self,
4300 predecessor_membership: &Membership,
4301 entry: LogEntry,
4302 ) -> Result<Self, ConfigError> {
4303 let target_config_id = self.config_id();
4304 if entry.cluster_id != self.cluster_id
4305 || entry.epoch != self.epoch
4306 || entry.config_id.checked_add(1) != Some(target_config_id)
4307 || entry.recompute_hash() != entry.hash
4308 {
4309 return Err(ConfigError::InvalidPredecessorTransition(
4310 "Stop identity does not match the successor draft".into(),
4311 ));
4312 }
4313 let ConfigChange::BoundStop { successor } =
4314 ConfigChange::recognize_parts(entry.entry_type, &entry.payload).map_err(|_| {
4315 ConfigError::InvalidPredecessorTransition(
4316 "predecessor entry is not a bound Stop".into(),
4317 )
4318 })?
4319 else {
4320 return Err(ConfigError::InvalidPredecessorTransition(
4321 "predecessor entry is not a bound Stop".into(),
4322 ));
4323 };
4324 if successor.cluster_id() != self.cluster_id
4325 || successor.predecessor_config_id() != entry.config_id
4326 || successor.predecessor_config_digest() != predecessor_membership.digest()
4327 || successor.config_id() != target_config_id
4328 || successor.digest() != self.membership.digest()
4329 || successor.members() != self.membership.members()
4330 {
4331 return Err(ConfigError::InvalidPredecessorTransition(
4332 "Stop successor descriptor does not match the successor draft".into(),
4333 ));
4334 }
4335 let predecessor =
4336 ConfigurationState::active(entry.config_id, predecessor_membership.digest());
4337 let stopped = predecessor
4338 .validate_entry(&entry)
4339 .map_err(|error| ConfigError::InvalidPredecessorTransition(error.to_string()))?;
4340 self.log_initial_configuration = predecessor;
4341 self.configuration_state = stopped;
4342 self.predecessor_stop_entry = Some(entry);
4343 Ok(self)
4344 }
4345
4346 pub fn with_recovery_generation(
4347 mut self,
4348 recovery_generation: u64,
4349 ) -> Result<Self, ConfigError> {
4350 validate_recovery_generation(recovery_generation)?;
4351 self.recovery_generation = recovery_generation;
4352 Ok(self)
4353 }
4354
4355 pub fn cluster_id(&self) -> &str {
4356 &self.cluster_id
4357 }
4358
4359 pub fn logical_cluster_id(&self) -> &str {
4360 &self.logical_cluster_id
4361 }
4362
4363 pub fn node_id(&self) -> &str {
4364 &self.node_id
4365 }
4366
4367 pub fn data_dir(&self) -> &PathBuf {
4368 &self.data_dir
4369 }
4370
4371 pub const fn epoch(&self) -> u64 {
4372 self.epoch
4373 }
4374
4375 pub const fn config_id(&self) -> u64 {
4376 self.configuration_state.config_id()
4377 }
4378
4379 pub const fn recovery_generation(&self) -> u64 {
4380 self.recovery_generation
4381 }
4382
4383 pub fn peers(&self) -> &[PeerConfig] {
4384 &self.peers
4385 }
4386
4387 pub const fn membership(&self) -> &Membership {
4388 &self.membership
4389 }
4390
4391 pub const fn configuration_state(&self) -> &ConfigurationState {
4392 &self.configuration_state
4393 }
4394
4395 pub const fn log_initial_configuration(&self) -> &ConfigurationState {
4396 &self.log_initial_configuration
4397 }
4398
4399 pub fn client_token(&self) -> &str {
4400 &self.client_token
4401 }
4402
4403 pub const fn read_consistency(&self) -> ReadConsistency {
4404 self.read_consistency
4405 }
4406
4407 pub const fn ack_mode(&self) -> AckMode {
4408 self.ack_mode
4409 }
4410
4411 pub const fn writer_batch_max(&self) -> usize {
4412 self.writer_batch_max
4413 }
4414
4415 pub const fn writer_batch_window(&self) -> Duration {
4416 self.writer_batch_window
4417 }
4418
4419 pub const fn execution_profile(&self) -> ExecutionProfile {
4420 self.execution_profile
4421 }
4422
4423 #[cfg(feature = "sql")]
4424 pub const fn sql_write_profiler(&self) -> Option<&SqlWriteProfiler> {
4425 self.sql_write_profiler.as_ref()
4426 }
4427
4428 #[cfg(feature = "sql")]
4429 pub const fn sql_group_commit_queue_capacity(&self) -> usize {
4430 self.sql_group_commit_queue_capacity
4431 }
4432}
4433
4434fn validate_node_identity(
4435 cluster_id: &str,
4436 node_id: &str,
4437 data_dir: &Path,
4438 epoch: u64,
4439 config_id: u64,
4440) -> Result<(), ConfigError> {
4441 if cluster_id.trim().is_empty() {
4442 return Err(ConfigError::EmptyClusterId);
4443 }
4444 if node_id.trim().is_empty() {
4445 return Err(ConfigError::EmptyNodeId);
4446 }
4447 if data_dir.as_os_str().is_empty() {
4448 return Err(ConfigError::EmptyDataDir);
4449 }
4450 if epoch == 0 {
4451 return Err(ConfigError::InvalidEpoch);
4452 }
4453 if config_id == 0 {
4454 return Err(ConfigError::InvalidConfigId);
4455 }
4456 Ok(())
4457}
4458
4459fn membership_from_node_ids(members: Vec<String>) -> Result<Membership, ConfigError> {
4460 if !(3..=7).contains(&members.len()) {
4461 return Err(ConfigError::InvalidPeerCount(members.len()));
4462 }
4463 Membership::from_voters(members.clone()).map_err(|error| match error {
4464 rhiza_quepaxa::Error::DuplicateRecorderIdentity => {
4465 let duplicate = members
4466 .iter()
4467 .find(|candidate| {
4468 members
4469 .iter()
4470 .filter(|member| *member == *candidate)
4471 .count()
4472 > 1
4473 })
4474 .cloned()
4475 .unwrap_or_default();
4476 ConfigError::DuplicatePeerNodeId(duplicate)
4477 }
4478 rhiza_quepaxa::Error::EmptyRecorderIdentity => ConfigError::EmptyPeerNodeId,
4479 _ => ConfigError::InvalidPeerCount(members.len()),
4480 })
4481}
4482
4483fn membership_from_peers(peers: &[PeerConfig]) -> Result<Membership, ConfigError> {
4484 membership_from_node_ids(peers.iter().map(|peer| peer.node_id.clone()).collect())
4485}
4486
4487#[derive(Clone, Debug, Eq, PartialEq)]
4488pub enum NodeError {
4489 UnsupportedAckMode(AckMode),
4490 ExecutionProfileMismatch {
4491 expected: ExecutionProfile,
4492 actual: ExecutionProfile,
4493 },
4494 DataRootLocked(PathBuf),
4495 SnapshotRequired(Box<RecoveryAnchor>),
4496 Storage(String),
4497 Reconciliation(String),
4498 Invariant(String),
4499 Unavailable(String),
4500 ResourceExhausted(String),
4501 ConfigurationTransition {
4502 state: Box<ConfigurationState>,
4503 },
4504 Contention(String),
4505 WinnerLimitExceeded,
4506 #[cfg(feature = "sql")]
4507 RequestConflict(RequestConflict),
4508 InvalidRequest(String),
4509 #[cfg(feature = "sql")]
4510 InvalidSqlStatement {
4511 statement_index: usize,
4512 message: String,
4513 },
4514 PreconditionFailed(String),
4515 Fatal(String),
4516}
4517
4518impl fmt::Display for NodeError {
4519 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4520 match self {
4521 Self::UnsupportedAckMode(mode) => {
4522 write!(
4523 f,
4524 "ack mode {mode:?} is unsupported without synchronous archive"
4525 )
4526 }
4527 Self::ExecutionProfileMismatch { expected, actual } => write!(
4528 f,
4529 "execution profile mismatch: expected {expected}, got {actual}"
4530 ),
4531 Self::DataRootLocked(path) => {
4532 write!(f, "node data root is already owned: {}", path.display())
4533 }
4534 Self::SnapshotRequired(anchor) => write!(
4535 f,
4536 "snapshot restore required at qlog anchor {}",
4537 anchor.compacted().index()
4538 ),
4539 Self::Storage(message) => write!(f, "node storage failed: {message}"),
4540 Self::Reconciliation(message) => write!(f, "node reconciliation failed: {message}"),
4541 Self::Invariant(message) => write!(f, "node invariant failed: {message}"),
4542 Self::Unavailable(message) => write!(f, "node unavailable: {message}"),
4543 Self::ResourceExhausted(message) => {
4544 write!(f, "node query resources exhausted: {message}")
4545 }
4546 Self::ConfigurationTransition { state } => write!(
4547 f,
4548 "node unavailable during configuration transition: {state:?}"
4549 ),
4550 Self::Contention(message) => write!(f, "node contention: {message}"),
4551 Self::WinnerLimitExceeded => write!(f, "foreign winner retry limit exceeded"),
4552 #[cfg(feature = "sql")]
4553 Self::RequestConflict(conflict) => conflict.fmt(f),
4554 Self::InvalidRequest(message) => write!(f, "invalid request: {message}"),
4555 #[cfg(feature = "sql")]
4556 Self::InvalidSqlStatement {
4557 statement_index,
4558 message,
4559 } => write!(
4560 f,
4561 "invalid SQL statement at index {statement_index}: {message}"
4562 ),
4563 Self::PreconditionFailed(message) => write!(f, "precondition failed: {message}"),
4564 Self::Fatal(message) => write!(f, "node is fatally unavailable: {message}"),
4565 }
4566 }
4567}
4568
4569impl std::error::Error for NodeError {}
4570
4571impl NodeError {
4572 pub fn classification(&self) -> ErrorClassification {
4573 let (code, retryable) = match self {
4574 Self::InvalidRequest(_) => ("invalid_request", false),
4575 #[cfg(feature = "sql")]
4576 Self::InvalidSqlStatement { .. } => ("invalid_request", false),
4577 #[cfg(feature = "sql")]
4578 Self::RequestConflict(_) => ("request_conflict", false),
4579 Self::PreconditionFailed(_) => ("precondition_failed", false),
4580 Self::SnapshotRequired(_) => ("snapshot_required", false),
4581 Self::Unavailable(_) => ("unavailable", true),
4582 Self::ResourceExhausted(_) => ("resource_exhausted", true),
4583 Self::ConfigurationTransition { .. } => ("configuration_transition", true),
4584 Self::Contention(_) => ("contention", true),
4585 Self::WinnerLimitExceeded => ("winner_limit_exceeded", true),
4586 Self::DataRootLocked(_) => ("data_root_locked", false),
4587 Self::UnsupportedAckMode(_) => ("unsupported_ack_mode", false),
4588 Self::ExecutionProfileMismatch { .. } => ("execution_profile_mismatch", false),
4589 Self::Storage(_) => ("storage_error", false),
4590 Self::Reconciliation(_) => ("reconciliation_error", false),
4591 Self::Invariant(_) => ("invariant_violation", false),
4592 Self::Fatal(_) => ("fatal", false),
4593 };
4594 ErrorClassification::from_server_code(code, retryable)
4595 }
4596}
4597
4598pub type RuntimeError = NodeError;
4599
4600#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4601#[serde(rename_all = "snake_case")]
4602pub enum RuntimeConfigurationStatus {
4603 Active,
4604 Stopped,
4605 AwaitingActivation,
4606}
4607
4608#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4609pub struct NodeStatus {
4610 pub ready: bool,
4611 pub configuration_status: RuntimeConfigurationStatus,
4612 pub configuration_state: ConfigurationState,
4613 pub stop_anchor: Option<rhiza_core::LogAnchor>,
4614 pub active_config_id: u64,
4615 pub active_membership_digest: LogHash,
4616}
4617
4618#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4619#[serde(deny_unknown_fields)]
4620pub struct StopInformation {
4621 pub entry: LogEntry,
4622 pub proof: DecisionProof,
4623}
4624
4625#[cfg(feature = "sql")]
4626#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4627#[serde(deny_unknown_fields)]
4628pub struct WriteRequest {
4629 pub request_id: String,
4630 pub key: String,
4631 pub value: String,
4632}
4633
4634#[cfg(feature = "sql")]
4635#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4636pub struct WriteResponse {
4637 pub applied_index: LogIndex,
4638 pub hash: LogHash,
4639}
4640
4641#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4642#[serde(deny_unknown_fields)]
4643pub struct ClientErrorResponse {
4644 pub code: String,
4645 pub retryable: bool,
4646 pub message: String,
4647 #[serde(skip_serializing_if = "Option::is_none")]
4648 pub statement_index: Option<usize>,
4649}
4650
4651#[cfg(feature = "sql")]
4652#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4653#[serde(deny_unknown_fields)]
4654pub struct ReadRequest {
4655 pub key: String,
4656 pub consistency: Option<ReadConsistency>,
4657}
4658
4659#[cfg(feature = "sql")]
4660#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4661pub struct ReadResponse {
4662 pub value: Option<String>,
4663 pub applied_index: LogIndex,
4664 pub hash: LogHash,
4665}
4666
4667#[cfg(feature = "sql")]
4668#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4669#[serde(deny_unknown_fields)]
4670pub struct SqlExecuteRequest {
4671 pub request_id: String,
4672 pub statements: Vec<SqlStatement>,
4673}
4674
4675#[cfg(feature = "sql")]
4676#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4677#[serde(deny_unknown_fields)]
4678pub struct SqlExecuteResponse {
4679 pub applied_index: LogIndex,
4680 pub hash: LogHash,
4681 pub results: Vec<SqlStatementResult>,
4682}
4683
4684#[cfg(feature = "sql")]
4685impl From<WriteResponse> for SqlExecuteResponse {
4686 fn from(response: WriteResponse) -> Self {
4687 sql_execute_response(response, None)
4688 }
4689}
4690
4691#[cfg(feature = "sql")]
4692fn sql_execute_response(
4693 response: WriteResponse,
4694 result: Option<SqlCommandResult>,
4695) -> SqlExecuteResponse {
4696 let results = result
4697 .map(|result| {
4698 result
4699 .statement_results
4700 .into_iter()
4701 .enumerate()
4702 .map(|(statement_index, result)| SqlStatementResult {
4703 statement_index,
4704 rows_affected: result.rows_affected,
4705 returning: result.returning,
4706 })
4707 .collect()
4708 })
4709 .unwrap_or_default();
4710 SqlExecuteResponse {
4711 applied_index: response.applied_index,
4712 hash: response.hash,
4713 results,
4714 }
4715}
4716
4717#[cfg(feature = "sql")]
4718#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4719pub struct SqlStatementResult {
4720 pub statement_index: usize,
4721 pub rows_affected: u64,
4722 #[serde(skip_serializing_if = "Option::is_none")]
4723 pub returning: Option<SqlQueryResult>,
4724}
4725
4726#[cfg(feature = "sql")]
4727#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4728#[serde(deny_unknown_fields)]
4729pub struct SqlQueryRequest {
4730 pub statement: SqlStatement,
4731 pub consistency: Option<ReadConsistency>,
4732 pub max_rows: Option<u32>,
4733}
4734
4735#[cfg(feature = "sql")]
4736#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4737pub struct SqlQueryResponse {
4738 pub columns: Vec<String>,
4739 pub rows: Vec<Vec<SqlValue>>,
4740 pub applied_index: LogIndex,
4741 pub hash: LogHash,
4742}
4743
4744#[cfg(feature = "graph")]
4745#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4746#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4747pub enum GraphValueDto {
4748 Null,
4749 Bool(bool),
4750 I64(i64),
4751 U64(u64),
4752 F64(f64),
4753 String(String),
4754 Bytes(String),
4755}
4756
4757#[cfg(feature = "graph")]
4758#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4759#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4760pub enum GraphQueryParameterDto {
4761 Null,
4762 Bool(bool),
4763 I64(i64),
4764 U64(u64),
4765 F64(f64),
4766 String(String),
4767 Bytes(String),
4768 List(Vec<Self>),
4769 Struct(BTreeMap<String, Self>),
4770}
4771
4772#[cfg(feature = "graph")]
4773impl TryFrom<GraphQueryParameterDto> for GraphParameterValue {
4774 type Error = NodeError;
4775
4776 fn try_from(value: GraphQueryParameterDto) -> Result<Self, Self::Error> {
4777 Ok(match value {
4778 GraphQueryParameterDto::Null => Self::Null,
4779 GraphQueryParameterDto::Bool(value) => Self::Bool(value),
4780 GraphQueryParameterDto::I64(value) => Self::I64(value),
4781 GraphQueryParameterDto::U64(value) => Self::U64(value),
4782 GraphQueryParameterDto::F64(value) => Self::F64(
4783 CanonicalF64::new(value)
4784 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?,
4785 ),
4786 GraphQueryParameterDto::String(value) => Self::String(value),
4787 GraphQueryParameterDto::Bytes(value) => {
4788 Self::Bytes(decode_base64("graph parameter bytes", &value)?)
4789 }
4790 GraphQueryParameterDto::List(values) => Self::List(
4791 values
4792 .into_iter()
4793 .map(Self::try_from)
4794 .collect::<Result<_, _>>()?,
4795 ),
4796 GraphQueryParameterDto::Struct(values) => Self::Struct(
4797 values
4798 .into_iter()
4799 .map(|(name, value)| Self::try_from(value).map(|value| (name, value)))
4800 .collect::<Result<_, _>>()?,
4801 ),
4802 })
4803 }
4804}
4805
4806#[cfg(feature = "graph")]
4807#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4808#[serde(deny_unknown_fields)]
4809pub struct GraphQueryStatementDto {
4810 pub cypher: String,
4811 #[serde(default)]
4812 pub parameters: BTreeMap<String, GraphQueryParameterDto>,
4813}
4814
4815#[cfg(feature = "graph")]
4816#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4817#[serde(deny_unknown_fields)]
4818pub struct GraphQueryRequest {
4819 pub statement: GraphQueryStatementDto,
4820 pub consistency: Option<ReadConsistency>,
4821 pub max_rows: Option<u32>,
4822}
4823
4824#[cfg(feature = "graph")]
4825#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4826pub struct GraphInternalIdDto {
4827 pub offset: u64,
4828 pub table_id: u64,
4829}
4830
4831#[cfg(feature = "graph")]
4832impl From<GraphInternalId> for GraphInternalIdDto {
4833 fn from(value: GraphInternalId) -> Self {
4834 Self {
4835 offset: value.offset,
4836 table_id: value.table_id,
4837 }
4838 }
4839}
4840
4841#[cfg(feature = "graph")]
4842#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4843pub struct GraphNamedValueDto {
4844 pub name: String,
4845 pub value: GraphResultValueDto,
4846}
4847
4848#[cfg(feature = "graph")]
4849#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4850pub struct GraphNodeDto {
4851 pub id: GraphInternalIdDto,
4852 pub label: String,
4853 pub properties: Vec<GraphNamedValueDto>,
4854}
4855
4856#[cfg(feature = "graph")]
4857impl From<GraphNode> for GraphNodeDto {
4858 fn from(value: GraphNode) -> Self {
4859 Self {
4860 id: value.id.into(),
4861 label: value.label,
4862 properties: named_graph_values(value.properties),
4863 }
4864 }
4865}
4866
4867#[cfg(feature = "graph")]
4868#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4869pub struct GraphRelDto {
4870 pub src: GraphInternalIdDto,
4871 pub dst: GraphInternalIdDto,
4872 pub label: String,
4873 pub properties: Vec<GraphNamedValueDto>,
4874}
4875
4876#[cfg(feature = "graph")]
4877impl From<GraphRel> for GraphRelDto {
4878 fn from(value: GraphRel) -> Self {
4879 Self {
4880 src: value.src.into(),
4881 dst: value.dst.into(),
4882 label: value.label,
4883 properties: named_graph_values(value.properties),
4884 }
4885 }
4886}
4887
4888#[cfg(feature = "graph")]
4889#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4890pub struct GraphRecursiveRelDto {
4891 pub nodes: Vec<GraphNodeDto>,
4892 pub rels: Vec<GraphRelDto>,
4893}
4894
4895#[cfg(feature = "graph")]
4896#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4897pub struct GraphMapEntryDto {
4898 pub key: GraphResultValueDto,
4899 pub value: GraphResultValueDto,
4900}
4901
4902#[cfg(feature = "graph")]
4903#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4904pub struct GraphNamedLogicalTypeDto {
4905 pub name: String,
4906 pub logical_type: GraphLogicalTypeDto,
4907}
4908
4909#[cfg(feature = "graph")]
4910#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4911pub struct GraphArrayTypeDto {
4912 pub element_type: Box<GraphLogicalTypeDto>,
4913 pub length: u64,
4914}
4915
4916#[cfg(feature = "graph")]
4917#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4918pub struct GraphMapTypeDto {
4919 pub key_type: Box<GraphLogicalTypeDto>,
4920 pub value_type: Box<GraphLogicalTypeDto>,
4921}
4922
4923#[cfg(feature = "graph")]
4924#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4925pub struct GraphDecimalTypeDto {
4926 pub precision: u32,
4927 pub scale: u32,
4928}
4929
4930#[cfg(feature = "graph")]
4931#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4932#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4933pub enum GraphLogicalTypeDto {
4934 Any,
4935 Bool,
4936 Serial,
4937 I64,
4938 I32,
4939 I16,
4940 I8,
4941 U64,
4942 U32,
4943 U16,
4944 U8,
4945 I128,
4946 F64,
4947 F32,
4948 Date,
4949 Interval,
4950 Timestamp,
4951 TimestampTz,
4952 TimestampNs,
4953 TimestampMs,
4954 TimestampSec,
4955 InternalId,
4956 String,
4957 Json,
4958 Bytes,
4959 List(Box<Self>),
4960 Array(GraphArrayTypeDto),
4961 Struct(Vec<GraphNamedLogicalTypeDto>),
4962 Node,
4963 Rel,
4964 RecursiveRel,
4965 Map(GraphMapTypeDto),
4966 Union(Vec<GraphNamedLogicalTypeDto>),
4967 Uuid,
4968 Decimal(GraphDecimalTypeDto),
4969}
4970
4971#[cfg(feature = "graph")]
4972impl From<GraphLogicalType> for GraphLogicalTypeDto {
4973 fn from(value: GraphLogicalType) -> Self {
4974 match value {
4975 GraphLogicalType::Any => Self::Any,
4976 GraphLogicalType::Bool => Self::Bool,
4977 GraphLogicalType::Serial => Self::Serial,
4978 GraphLogicalType::I64 => Self::I64,
4979 GraphLogicalType::I32 => Self::I32,
4980 GraphLogicalType::I16 => Self::I16,
4981 GraphLogicalType::I8 => Self::I8,
4982 GraphLogicalType::U64 => Self::U64,
4983 GraphLogicalType::U32 => Self::U32,
4984 GraphLogicalType::U16 => Self::U16,
4985 GraphLogicalType::U8 => Self::U8,
4986 GraphLogicalType::I128 => Self::I128,
4987 GraphLogicalType::F64 => Self::F64,
4988 GraphLogicalType::F32 => Self::F32,
4989 GraphLogicalType::Date => Self::Date,
4990 GraphLogicalType::Interval => Self::Interval,
4991 GraphLogicalType::Timestamp => Self::Timestamp,
4992 GraphLogicalType::TimestampTz => Self::TimestampTz,
4993 GraphLogicalType::TimestampNs => Self::TimestampNs,
4994 GraphLogicalType::TimestampMs => Self::TimestampMs,
4995 GraphLogicalType::TimestampSec => Self::TimestampSec,
4996 GraphLogicalType::InternalId => Self::InternalId,
4997 GraphLogicalType::String => Self::String,
4998 GraphLogicalType::Json => Self::Json,
4999 GraphLogicalType::Bytes => Self::Bytes,
5000 GraphLogicalType::List(element_type) => Self::List(Box::new((*element_type).into())),
5001 GraphLogicalType::Array {
5002 element_type,
5003 length,
5004 } => Self::Array(GraphArrayTypeDto {
5005 element_type: Box::new((*element_type).into()),
5006 length,
5007 }),
5008 GraphLogicalType::Struct(fields) => Self::Struct(named_graph_logical_types(fields)),
5009 GraphLogicalType::Node => Self::Node,
5010 GraphLogicalType::Rel => Self::Rel,
5011 GraphLogicalType::RecursiveRel => Self::RecursiveRel,
5012 GraphLogicalType::Map {
5013 key_type,
5014 value_type,
5015 } => Self::Map(GraphMapTypeDto {
5016 key_type: Box::new((*key_type).into()),
5017 value_type: Box::new((*value_type).into()),
5018 }),
5019 GraphLogicalType::Union(types) => Self::Union(named_graph_logical_types(types)),
5020 GraphLogicalType::Uuid => Self::Uuid,
5021 GraphLogicalType::Decimal { precision, scale } => {
5022 Self::Decimal(GraphDecimalTypeDto { precision, scale })
5023 }
5024 }
5025 }
5026}
5027
5028#[cfg(feature = "graph")]
5029#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5030pub struct GraphCollectionValueDto {
5031 pub element_type: GraphLogicalTypeDto,
5032 pub values: Vec<GraphResultValueDto>,
5033}
5034
5035#[cfg(feature = "graph")]
5036#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5037pub struct GraphMapValueDto {
5038 pub key_type: GraphLogicalTypeDto,
5039 pub value_type: GraphLogicalTypeDto,
5040 pub entries: Vec<GraphMapEntryDto>,
5041}
5042
5043#[cfg(feature = "graph")]
5044#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5045pub struct GraphUnionValueDto {
5046 pub variants: Vec<GraphNamedLogicalTypeDto>,
5047 pub value: Box<GraphResultValueDto>,
5048}
5049
5050#[cfg(feature = "graph")]
5051#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5052#[serde(tag = "type", content = "value", rename_all = "snake_case")]
5053pub enum GraphResultValueDto {
5054 Null(GraphLogicalTypeDto),
5055 Bool(bool),
5056 I64(i64),
5057 I32(i32),
5058 I16(i16),
5059 I8(i8),
5060 U64(u64),
5061 U32(u32),
5062 U16(u16),
5063 U8(u8),
5064 I128(String),
5065 F64(f64),
5066 F32(String),
5067 Date(String),
5068 Interval(String),
5069 Timestamp(String),
5070 TimestampTz(String),
5071 TimestampNs(String),
5072 TimestampMs(String),
5073 TimestampSec(String),
5074 InternalId(GraphInternalIdDto),
5075 String(String),
5076 Json(String),
5077 Bytes(String),
5078 List(GraphCollectionValueDto),
5079 Array(GraphCollectionValueDto),
5080 Struct(Vec<GraphNamedValueDto>),
5081 Node(GraphNodeDto),
5082 Rel(GraphRelDto),
5083 RecursiveRel(GraphRecursiveRelDto),
5084 Map(GraphMapValueDto),
5085 Union(GraphUnionValueDto),
5086 Uuid(String),
5087 Decimal(String),
5088}
5089
5090#[cfg(feature = "graph")]
5091impl From<GraphResultValue> for GraphResultValueDto {
5092 fn from(value: GraphResultValue) -> Self {
5093 match value {
5094 GraphResultValue::Null(value) => Self::Null(value.into()),
5095 GraphResultValue::Bool(value) => Self::Bool(value),
5096 GraphResultValue::I64(value) => Self::I64(value),
5097 GraphResultValue::I32(value) => Self::I32(value),
5098 GraphResultValue::I16(value) => Self::I16(value),
5099 GraphResultValue::I8(value) => Self::I8(value),
5100 GraphResultValue::U64(value) => Self::U64(value),
5101 GraphResultValue::U32(value) => Self::U32(value),
5102 GraphResultValue::U16(value) => Self::U16(value),
5103 GraphResultValue::U8(value) => Self::U8(value),
5104 GraphResultValue::I128(value) => Self::I128(value),
5105 GraphResultValue::F64(value) => Self::F64(value.get()),
5106 GraphResultValue::F32(value) => Self::F32(value),
5107 GraphResultValue::Date(value) => Self::Date(value),
5108 GraphResultValue::Interval(value) => Self::Interval(value),
5109 GraphResultValue::Timestamp(value) => Self::Timestamp(value),
5110 GraphResultValue::TimestampTz(value) => Self::TimestampTz(value),
5111 GraphResultValue::TimestampNs(value) => Self::TimestampNs(value),
5112 GraphResultValue::TimestampMs(value) => Self::TimestampMs(value),
5113 GraphResultValue::TimestampSec(value) => Self::TimestampSec(value),
5114 GraphResultValue::InternalId(value) => Self::InternalId(value.into()),
5115 GraphResultValue::String(value) => Self::String(value),
5116 GraphResultValue::Json(value) => Self::Json(value),
5117 GraphResultValue::Bytes(value) => Self::Bytes(encode_base64(&value)),
5118 GraphResultValue::List {
5119 element_type,
5120 values,
5121 } => Self::List(GraphCollectionValueDto {
5122 element_type: element_type.into(),
5123 values: values.into_iter().map(Self::from).collect(),
5124 }),
5125 GraphResultValue::Array {
5126 element_type,
5127 values,
5128 } => Self::Array(GraphCollectionValueDto {
5129 element_type: element_type.into(),
5130 values: values.into_iter().map(Self::from).collect(),
5131 }),
5132 GraphResultValue::Struct(values) => Self::Struct(named_graph_values(values)),
5133 GraphResultValue::Node(value) => Self::Node(value.into()),
5134 GraphResultValue::Rel(value) => Self::Rel(value.into()),
5135 GraphResultValue::RecursiveRel { nodes, rels } => {
5136 Self::RecursiveRel(GraphRecursiveRelDto {
5137 nodes: nodes.into_iter().map(Into::into).collect(),
5138 rels: rels.into_iter().map(Into::into).collect(),
5139 })
5140 }
5141 GraphResultValue::Map {
5142 key_type,
5143 value_type,
5144 entries,
5145 } => Self::Map(GraphMapValueDto {
5146 key_type: key_type.into(),
5147 value_type: value_type.into(),
5148 entries: entries
5149 .into_iter()
5150 .map(|(key, value)| GraphMapEntryDto {
5151 key: key.into(),
5152 value: value.into(),
5153 })
5154 .collect(),
5155 }),
5156 GraphResultValue::Union { variants, value } => Self::Union(GraphUnionValueDto {
5157 variants: named_graph_logical_types(variants),
5158 value: Box::new(Self::from(*value)),
5159 }),
5160 GraphResultValue::Uuid(value) => Self::Uuid(value),
5161 GraphResultValue::Decimal(value) => Self::Decimal(value),
5162 }
5163 }
5164}
5165
5166#[cfg(feature = "graph")]
5167fn named_graph_values(values: Vec<(String, GraphResultValue)>) -> Vec<GraphNamedValueDto> {
5168 values
5169 .into_iter()
5170 .map(|(name, value)| GraphNamedValueDto {
5171 name,
5172 value: value.into(),
5173 })
5174 .collect()
5175}
5176
5177#[cfg(feature = "graph")]
5178fn named_graph_logical_types(
5179 values: Vec<(String, GraphLogicalType)>,
5180) -> Vec<GraphNamedLogicalTypeDto> {
5181 values
5182 .into_iter()
5183 .map(|(name, logical_type)| GraphNamedLogicalTypeDto {
5184 name,
5185 logical_type: logical_type.into(),
5186 })
5187 .collect()
5188}
5189
5190#[cfg(feature = "graph")]
5191#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5192pub struct GraphColumnDto {
5193 pub name: String,
5194 pub logical_type: GraphLogicalTypeDto,
5195}
5196
5197#[cfg(feature = "graph")]
5198impl From<GraphColumn> for GraphColumnDto {
5199 fn from(value: GraphColumn) -> Self {
5200 Self {
5201 name: value.name,
5202 logical_type: value.logical_type.into(),
5203 }
5204 }
5205}
5206
5207#[cfg(feature = "graph")]
5208#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5209pub struct GraphQueryResponse {
5210 pub columns: Vec<GraphColumnDto>,
5211 pub rows: Vec<Vec<GraphResultValueDto>>,
5212 pub applied_index: LogIndex,
5213 pub hash: LogHash,
5214}
5215
5216#[cfg(feature = "graph")]
5217impl From<GraphQueryResult> for GraphQueryResponse {
5218 fn from(value: GraphQueryResult) -> Self {
5219 Self {
5220 columns: value.columns.into_iter().map(Into::into).collect(),
5221 rows: value
5222 .rows
5223 .into_iter()
5224 .map(|row| row.into_iter().map(Into::into).collect())
5225 .collect(),
5226 applied_index: value.applied_index,
5227 hash: value.hash,
5228 }
5229 }
5230}
5231
5232#[cfg(feature = "graph")]
5233#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5234#[serde(deny_unknown_fields)]
5235pub struct GraphPutDocumentRequest {
5236 pub request_id: String,
5237 pub id: String,
5238 pub value: GraphValueDto,
5239}
5240
5241#[cfg(feature = "graph")]
5242#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5243#[serde(deny_unknown_fields)]
5244pub struct GraphDeleteDocumentRequest {
5245 pub request_id: String,
5246 pub id: String,
5247}
5248
5249#[cfg(feature = "graph")]
5250#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5251#[serde(deny_unknown_fields)]
5252pub struct GraphGetDocumentRequest {
5253 pub id: String,
5254 pub consistency: Option<ReadConsistency>,
5255}
5256
5257#[cfg(feature = "graph")]
5258#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5259#[serde(tag = "operation", rename_all = "snake_case")]
5260pub enum GraphMutationResultDto {
5261 PutDocument { created: bool },
5262 DeleteDocument { existed: bool },
5263}
5264
5265#[cfg(feature = "graph")]
5266#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5267pub struct GraphMutationResponse {
5268 pub applied_index: LogIndex,
5269 pub hash: LogHash,
5270 pub result: GraphMutationResultDto,
5271}
5272
5273#[cfg(feature = "graph")]
5274#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5275pub struct GraphGetDocumentResponse {
5276 pub value: Option<GraphValueDto>,
5277 pub applied_index: LogIndex,
5278 pub hash: LogHash,
5279}
5280
5281#[cfg(feature = "kv")]
5282#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5283#[serde(deny_unknown_fields)]
5284pub struct KvPutRequest {
5285 pub request_id: String,
5286 pub key: String,
5287 pub value: String,
5288}
5289
5290#[cfg(feature = "kv")]
5291#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5292#[serde(deny_unknown_fields)]
5293pub struct KvDeleteRequest {
5294 pub request_id: String,
5295 pub key: String,
5296}
5297
5298#[cfg(feature = "kv")]
5299#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5300#[serde(deny_unknown_fields)]
5301pub struct KvGetRequest {
5302 pub key: String,
5303 pub consistency: Option<ReadConsistency>,
5304}
5305
5306#[cfg(feature = "kv")]
5307#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5308#[serde(deny_unknown_fields)]
5309pub struct KvScanRequest {
5310 pub start: Option<String>,
5311 pub end: Option<String>,
5312 pub prefix: Option<String>,
5313 pub cursor: Option<String>,
5314 pub limit: Option<usize>,
5315 pub consistency: Option<ReadConsistency>,
5316}
5317
5318#[cfg(feature = "kv")]
5319#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5320#[serde(tag = "operation", rename_all = "snake_case")]
5321pub enum KvMutationResultDto {
5322 Put { replaced: bool },
5323 Delete { existed: bool },
5324}
5325
5326#[cfg(feature = "kv")]
5327#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5328pub struct KvMutationResponse {
5329 pub applied_index: LogIndex,
5330 pub hash: LogHash,
5331 pub result: KvMutationResultDto,
5332}
5333
5334#[cfg(feature = "kv")]
5335#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5336pub struct KvGetResponse {
5337 pub value: Option<String>,
5338 pub applied_index: LogIndex,
5339 pub hash: LogHash,
5340}
5341
5342#[cfg(feature = "kv")]
5343#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5344pub struct KvScanEntryDto {
5345 pub key: String,
5346 pub value: String,
5347}
5348
5349#[cfg(feature = "kv")]
5350#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5351pub struct KvScanResponse {
5352 pub entries: Vec<KvScanEntryDto>,
5353 pub next_cursor: Option<String>,
5354 pub applied_index: LogIndex,
5355 pub hash: LogHash,
5356}
5357
5358#[cfg(feature = "graph")]
5359fn graph_mutation_response(outcome: GraphMutationOutcome) -> GraphMutationResponse {
5360 GraphMutationResponse {
5361 applied_index: outcome.applied_index(),
5362 hash: outcome.hash(),
5363 result: match outcome.result() {
5364 GraphCommandResultV1::PutDocument { created } => {
5365 GraphMutationResultDto::PutDocument { created: *created }
5366 }
5367 GraphCommandResultV1::DeleteDocument { existed } => {
5368 GraphMutationResultDto::DeleteDocument { existed: *existed }
5369 }
5370 },
5371 }
5372}
5373
5374#[cfg(feature = "kv")]
5375fn kv_mutation_response(outcome: KvMutationOutcome) -> KvMutationResponse {
5376 KvMutationResponse {
5377 applied_index: outcome.applied_index(),
5378 hash: outcome.hash(),
5379 result: match outcome.result() {
5380 KvCommandResultV1::Put { replaced } => KvMutationResultDto::Put {
5381 replaced: *replaced,
5382 },
5383 KvCommandResultV1::Delete { existed } => {
5384 KvMutationResultDto::Delete { existed: *existed }
5385 }
5386 },
5387 }
5388}
5389
5390#[cfg(feature = "kv")]
5391fn validate_kv_scan_required_index(
5392 result: &KvScanResult,
5393 required_index: Option<LogIndex>,
5394) -> Result<(), NodeError> {
5395 let applied_index = result.tip().applied_index();
5396 if required_index.is_some_and(|required| applied_index < required) {
5397 return Err(NodeError::Unavailable(format!(
5398 "local applied index {applied_index} has not reached {}",
5399 required_index.expect("checked above")
5400 )));
5401 }
5402 Ok(())
5403}
5404
5405#[cfg(feature = "graph")]
5406impl TryFrom<GraphValueDto> for GraphValueV1 {
5407 type Error = NodeError;
5408
5409 fn try_from(value: GraphValueDto) -> Result<Self, Self::Error> {
5410 match value {
5411 GraphValueDto::Null => Ok(Self::Null),
5412 GraphValueDto::Bool(value) => Ok(Self::Bool(value)),
5413 GraphValueDto::I64(value) => Ok(Self::I64(value)),
5414 GraphValueDto::U64(value) => Ok(Self::U64(value)),
5415 GraphValueDto::F64(value) => {
5416 Self::from_f64(value).map_err(|error| NodeError::InvalidRequest(error.to_string()))
5417 }
5418 GraphValueDto::String(value) => Ok(Self::String(value)),
5419 GraphValueDto::Bytes(value) => decode_base64("value", &value).map(Self::Bytes),
5420 }
5421 }
5422}
5423
5424#[cfg(feature = "graph")]
5425impl From<GraphValueV1> for GraphValueDto {
5426 fn from(value: GraphValueV1) -> Self {
5427 match value {
5428 GraphValueV1::Null => Self::Null,
5429 GraphValueV1::Bool(value) => Self::Bool(value),
5430 GraphValueV1::I64(value) => Self::I64(value),
5431 GraphValueV1::U64(value) => Self::U64(value),
5432 GraphValueV1::F64(value) => Self::F64(value.get()),
5433 GraphValueV1::String(value) => Self::String(value),
5434 GraphValueV1::Bytes(value) => Self::Bytes(encode_base64(&value)),
5435 }
5436 }
5437}
5438
5439#[cfg(any(feature = "graph", feature = "kv"))]
5440fn encode_base64(bytes: &[u8]) -> String {
5441 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
5442 let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4);
5443 for chunk in bytes.chunks(3) {
5444 let first = chunk[0];
5445 let second = chunk.get(1).copied().unwrap_or(0);
5446 let third = chunk.get(2).copied().unwrap_or(0);
5447 encoded.push(ALPHABET[usize::from(first >> 2)] as char);
5448 encoded.push(ALPHABET[usize::from(((first & 0x03) << 4) | (second >> 4))] as char);
5449 if chunk.len() > 1 {
5450 encoded.push(ALPHABET[usize::from(((second & 0x0f) << 2) | (third >> 6))] as char);
5451 } else {
5452 encoded.push('=');
5453 }
5454 if chunk.len() > 2 {
5455 encoded.push(ALPHABET[usize::from(third & 0x3f)] as char);
5456 } else {
5457 encoded.push('=');
5458 }
5459 }
5460 encoded
5461}
5462
5463#[cfg(any(feature = "graph", feature = "kv"))]
5464fn decode_base64(field: &str, encoded: &str) -> Result<Vec<u8>, NodeError> {
5465 fn sextet(byte: u8) -> Option<u8> {
5466 match byte {
5467 b'A'..=b'Z' => Some(byte - b'A'),
5468 b'a'..=b'z' => Some(byte - b'a' + 26),
5469 b'0'..=b'9' => Some(byte - b'0' + 52),
5470 b'+' => Some(62),
5471 b'/' => Some(63),
5472 _ => None,
5473 }
5474 }
5475
5476 let bytes = encoded.as_bytes();
5477 if !bytes.len().is_multiple_of(4) {
5478 return Err(NodeError::InvalidRequest(format!(
5479 "{field} must be canonical padded base64"
5480 )));
5481 }
5482 let mut decoded = Vec::with_capacity(bytes.len() / 4 * 3);
5483 for (chunk_index, chunk) in bytes.chunks_exact(4).enumerate() {
5484 let last = chunk_index + 1 == bytes.len() / 4;
5485 let first = sextet(chunk[0]);
5486 let second = sextet(chunk[1]);
5487 let third = (chunk[2] != b'=').then(|| sextet(chunk[2])).flatten();
5488 let fourth = (chunk[3] != b'=').then(|| sextet(chunk[3])).flatten();
5489 let has_padding = chunk[2] == b'=' || chunk[3] == b'=';
5490 if first.is_none()
5491 || second.is_none()
5492 || (chunk[2] != b'=' && third.is_none())
5493 || (chunk[3] != b'=' && fourth.is_none())
5494 || (!last && has_padding)
5495 || (chunk[2] == b'=' && chunk[3] != b'=')
5496 {
5497 return Err(NodeError::InvalidRequest(format!(
5498 "{field} must be canonical padded base64"
5499 )));
5500 }
5501 let first = first.unwrap();
5502 let second = second.unwrap();
5503 decoded.push((first << 2) | (second >> 4));
5504 if let Some(third) = third {
5505 decoded.push((second << 4) | (third >> 2));
5506 if let Some(fourth) = fourth {
5507 decoded.push((third << 6) | fourth);
5508 } else if third & 0x03 != 0 {
5509 return Err(NodeError::InvalidRequest(format!(
5510 "{field} must be canonical padded base64"
5511 )));
5512 }
5513 } else if second & 0x0f != 0 {
5514 return Err(NodeError::InvalidRequest(format!(
5515 "{field} must be canonical padded base64"
5516 )));
5517 }
5518 }
5519 Ok(decoded)
5520}
5521
5522enum Materializer {
5523 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5524 Unavailable,
5525 #[cfg(feature = "sql")]
5526 Sql(Box<SqliteStateMachine>),
5527 #[cfg(feature = "graph")]
5528 Graph(Arc<LadybugStateMachine>),
5529 #[cfg(feature = "kv")]
5530 Kv(Arc<RedbStateMachine>),
5531}
5532
5533#[cfg(any(feature = "sql", feature = "kv"))]
5534fn quarantine_materializer(data_dir: &Path, directory: &str) -> Result<(), NodeError> {
5535 static SEQUENCE: AtomicUsize = AtomicUsize::new(0);
5536 let source = data_dir.join(directory);
5537 if !source.exists() {
5538 return Ok(());
5539 }
5540 loop {
5541 let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
5542 let target = data_dir.join(format!(
5543 "{directory}.quarantine-{}-{sequence}",
5544 std::process::id()
5545 ));
5546 match fs::rename(&source, target) {
5547 Ok(()) => return Ok(()),
5548 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
5549 Err(error) => return Err(NodeError::Storage(error.to_string())),
5550 }
5551 }
5552}
5553
5554#[cfg(feature = "sql")]
5555struct SqlMaterializerGuard<'a>(MutexGuard<'a, Materializer>);
5556
5557#[cfg(feature = "sql")]
5558impl std::ops::Deref for SqlMaterializerGuard<'_> {
5559 type Target = SqliteStateMachine;
5560
5561 fn deref(&self) -> &Self::Target {
5562 match &*self.0 {
5563 Materializer::Sql(state) => state,
5564 #[cfg(feature = "graph")]
5565 Materializer::Graph(_) => unreachable!("SQL guard validated the materializer profile"),
5566 #[cfg(feature = "kv")]
5567 Materializer::Kv(_) => unreachable!("SQL guard validated the materializer profile"),
5568 }
5569 }
5570}
5571
5572impl Materializer {
5573 fn close_for_handoff(self) -> Result<(), String> {
5574 match self {
5575 #[cfg(feature = "sql")]
5576 Self::Sql(state) => state.close_for_handoff().map_err(|error| error.to_string()),
5577 #[cfg(feature = "graph")]
5578 Self::Graph(_) => Ok(()),
5579 #[cfg(feature = "kv")]
5580 Self::Kv(_) => Ok(()),
5581 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5582 Self::Unavailable => Ok(()),
5583 }
5584 }
5585
5586 fn ensure_profile_available(profile: ExecutionProfile) -> Result<(), NodeError> {
5587 if execution_profile_compiled(profile) {
5588 Ok(())
5589 } else {
5590 Err(NodeError::Unavailable(format!(
5591 "{} execution profile is not compiled in",
5592 profile.as_str()
5593 )))
5594 }
5595 }
5596
5597 #[cfg_attr(not(feature = "sql"), allow(unused_variables))]
5598 fn open(
5599 config: &NodeConfig,
5600 configuration_state: &ConfigurationState,
5601 recovery_anchor: Option<&RecoveryAnchor>,
5602 ) -> Result<Self, NodeError> {
5603 match config.execution_profile() {
5604 ExecutionProfile::Sqlite => {
5605 #[cfg(feature = "sql")]
5606 {
5607 let path = config.data_dir().join("sqlite/db.sqlite");
5608 let open = || {
5609 rhiza_sql::cleanup_empty_wal_sidecars_after_owner_fence(&path)?;
5610 SqliteStateMachine::open_with_configuration(
5611 &path,
5612 config.cluster_id(),
5613 config.node_id(),
5614 config.epoch(),
5615 configuration_state.clone(),
5616 )
5617 };
5618 let state = match open() {
5619 Ok(state) => state,
5620 Err(open_error) => match recovery_anchor {
5621 Some(anchor) => {
5622 eprintln!(
5623 "SQL materializer strict reopen failed before snapshot recovery: {open_error}"
5624 );
5625 return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())));
5626 }
5627 None => {
5628 quarantine_materializer(config.data_dir(), "sqlite")?;
5629 open().map_err(|error| NodeError::Storage(error.to_string()))?
5630 }
5631 },
5632 };
5633 Ok(Self::Sql(Box::new(state)))
5634 }
5635 #[cfg(not(feature = "sql"))]
5636 Err(NodeError::Unavailable(
5637 "sql execution profile is not compiled in".into(),
5638 ))
5639 }
5640 ExecutionProfile::Graph => {
5641 #[cfg(feature = "graph")]
5642 {
5643 LadybugStateMachine::open(
5644 config.data_dir().join("ladybug/graph.lbug"),
5645 config.cluster_id(),
5646 config.node_id(),
5647 config.epoch(),
5648 configuration_state.config_id(),
5649 )
5650 .map(Arc::new)
5651 .map(Self::Graph)
5652 .map_err(|error| NodeError::Storage(error.to_string()))
5653 }
5654 #[cfg(not(feature = "graph"))]
5655 Err(NodeError::Unavailable(
5656 "graph execution profile is not compiled in".into(),
5657 ))
5658 }
5659 ExecutionProfile::Kv => {
5660 #[cfg(feature = "kv")]
5661 {
5662 let open = || {
5663 RedbStateMachine::open(
5664 config.data_dir().join("kv/data.redb"),
5665 config.cluster_id(),
5666 config.node_id(),
5667 config.epoch(),
5668 configuration_state.config_id(),
5669 )
5670 };
5671 let state = match open() {
5672 Ok(state) => state,
5673 Err(_) => match recovery_anchor {
5674 Some(anchor) => {
5675 return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())))
5676 }
5677 _ => {
5678 quarantine_materializer(config.data_dir(), "kv")?;
5679 open().map_err(|error| NodeError::Storage(error.to_string()))?
5680 }
5681 },
5682 };
5683 Ok(Self::Kv(Arc::new(state)))
5684 }
5685 #[cfg(not(feature = "kv"))]
5686 Err(NodeError::Unavailable(
5687 "kv execution profile is not compiled in".into(),
5688 ))
5689 }
5690 }
5691 }
5692
5693 #[cfg_attr(not(feature = "sql"), allow(unused_variables))]
5694 fn open_detached(
5695 data_dir: &Path,
5696 profile: ExecutionProfile,
5697 cluster_id: &str,
5698 node_id: &str,
5699 epoch: u64,
5700 configuration_state: &ConfigurationState,
5701 ) -> Result<Self, NodeError> {
5702 Self::ensure_profile_available(profile)?;
5703 match profile {
5704 ExecutionProfile::Sqlite => {
5705 #[cfg(feature = "sql")]
5706 {
5707 SqliteStateMachine::open_with_configuration(
5708 data_dir.join("sqlite/db.sqlite"),
5709 cluster_id,
5710 node_id,
5711 epoch,
5712 configuration_state.clone(),
5713 )
5714 .map(Box::new)
5715 .map(Self::Sql)
5716 .map_err(|error| NodeError::Storage(error.to_string()))
5717 }
5718 #[cfg(not(feature = "sql"))]
5719 Err(NodeError::Unavailable(
5720 "sql execution profile is not compiled in".into(),
5721 ))
5722 }
5723 ExecutionProfile::Graph => {
5724 #[cfg(feature = "graph")]
5725 {
5726 LadybugStateMachine::open(
5727 data_dir.join("ladybug/graph.lbug"),
5728 cluster_id,
5729 node_id,
5730 epoch,
5731 configuration_state.config_id(),
5732 )
5733 .map(Arc::new)
5734 .map(Self::Graph)
5735 .map_err(|error| NodeError::Storage(error.to_string()))
5736 }
5737 #[cfg(not(feature = "graph"))]
5738 Err(NodeError::Unavailable(
5739 "graph execution profile is not compiled in".into(),
5740 ))
5741 }
5742 ExecutionProfile::Kv => {
5743 #[cfg(feature = "kv")]
5744 {
5745 RedbStateMachine::open(
5746 data_dir.join("kv/data.redb"),
5747 cluster_id,
5748 node_id,
5749 epoch,
5750 configuration_state.config_id(),
5751 )
5752 .map(Arc::new)
5753 .map(Self::Kv)
5754 .map_err(|error| NodeError::Storage(error.to_string()))
5755 }
5756 #[cfg(not(feature = "kv"))]
5757 Err(NodeError::Unavailable(
5758 "kv execution profile is not compiled in".into(),
5759 ))
5760 }
5761 }
5762 }
5763
5764 fn profile(&self) -> ExecutionProfile {
5765 match self {
5766 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5767 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5768 #[cfg(feature = "sql")]
5769 Self::Sql(_) => ExecutionProfile::Sqlite,
5770 #[cfg(feature = "graph")]
5771 Self::Graph(_) => ExecutionProfile::Graph,
5772 #[cfg(feature = "kv")]
5773 Self::Kv(_) => ExecutionProfile::Kv,
5774 }
5775 }
5776
5777 fn applied_index(&self) -> Result<LogIndex, String> {
5778 match self {
5779 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5780 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5781 #[cfg(feature = "sql")]
5782 Self::Sql(state) => state
5783 .applied_index_value()
5784 .map_err(|error| error.to_string()),
5785 #[cfg(feature = "graph")]
5786 Self::Graph(state) => state.applied_index().map_err(|error| error.to_string()),
5787 #[cfg(feature = "kv")]
5788 Self::Kv(state) => state.applied_index().map_err(|error| error.to_string()),
5789 }
5790 }
5791
5792 fn applied_hash(&self) -> Result<LogHash, String> {
5793 match self {
5794 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5795 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5796 #[cfg(feature = "sql")]
5797 Self::Sql(state) => state
5798 .applied_hash_value()
5799 .map_err(|error| error.to_string()),
5800 #[cfg(feature = "graph")]
5801 Self::Graph(state) => state.applied_hash().map_err(|error| error.to_string()),
5802 #[cfg(feature = "kv")]
5803 Self::Kv(state) => state.applied_hash().map_err(|error| error.to_string()),
5804 }
5805 }
5806
5807 fn applied_tip(&self) -> Result<LogAnchor, String> {
5808 match self {
5809 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5810 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5811 #[cfg(feature = "sql")]
5812 Self::Sql(state) => state
5813 .applied_tip()
5814 .map(|tip| LogAnchor::new(tip.applied_index(), tip.applied_hash()))
5815 .map_err(|error| error.to_string()),
5816 #[cfg(feature = "graph")]
5817 Self::Graph(state) => state.applied_tip().map_err(|error| error.to_string()),
5818 #[cfg(feature = "kv")]
5819 Self::Kv(state) => state.applied_tip().map_err(|error| error.to_string()),
5820 }
5821 }
5822
5823 fn configuration_state(&self) -> Result<Option<ConfigurationState>, String> {
5824 match self {
5825 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5826 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5827 #[cfg(feature = "sql")]
5828 Self::Sql(state) => state
5829 .configuration_state_value()
5830 .map(Some)
5831 .map_err(|error| error.to_string()),
5832 #[cfg(feature = "graph")]
5833 Self::Graph(_) => Ok(None),
5834 #[cfg(feature = "kv")]
5835 Self::Kv(_) => Ok(None),
5836 }
5837 }
5838
5839 fn apply_entry(&self, entry: &LogEntry) -> Result<Option<SqlCommandResult>, String> {
5840 match self {
5841 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5842 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5843 #[cfg(feature = "sql")]
5844 Self::Sql(state) => state
5845 .apply_entry_with_result(entry)
5846 .map(|outcome| outcome.sql_result().cloned())
5847 .map_err(|error| error.to_string()),
5848 #[cfg(feature = "graph")]
5849 Self::Graph(state) => state
5850 .apply_entry(entry)
5851 .map(|_| None)
5852 .map_err(|error| error.to_string()),
5853 #[cfg(feature = "kv")]
5854 Self::Kv(state) => state
5855 .apply_entry(entry)
5856 .map(|_| None)
5857 .map_err(|error| error.to_string()),
5858 }
5859 }
5860}
5861
5862const READ_BARRIER_COALESCE_WINDOW: Duration = Duration::from_micros(50);
5863
5864struct ReadBarrierRounds {
5865 state: Mutex<ReadBarrierRoundsState>,
5866 collection_window: Duration,
5867}
5868
5869struct ReadBarrierRoundsState {
5870 tail: Option<Arc<ReadBarrierRound>>,
5871 next_generation: u64,
5872}
5873
5874struct ReadBarrierRound {
5875 #[cfg(test)]
5876 generation: u64,
5877 collection_deadline: Instant,
5878 predecessor: Mutex<Option<Arc<ReadBarrierRound>>>,
5879 phase: Mutex<ReadBarrierRoundPhase>,
5880 changed: Condvar,
5881}
5882
5883#[derive(Clone)]
5884enum ReadBarrierRoundPhase {
5885 Collecting,
5886 Running,
5887 Complete(Result<LogAnchor, NodeError>),
5888}
5889
5890struct ReadBarrierParticipant {
5891 round: Arc<ReadBarrierRound>,
5892 leader: bool,
5893}
5894
5895struct ReadBarrierPublication {
5896 round: Arc<ReadBarrierRound>,
5897 published: bool,
5898}
5899
5900impl ReadBarrierRounds {
5901 fn new(collection_window: Duration) -> Self {
5902 Self {
5903 state: Mutex::new(ReadBarrierRoundsState {
5904 tail: None,
5905 next_generation: 0,
5906 }),
5907 collection_window,
5908 }
5909 }
5910
5911 fn join(&self) -> Result<ReadBarrierParticipant, NodeError> {
5912 let mut state = self
5913 .state
5914 .lock()
5915 .map_err(|_| NodeError::Invariant("read barrier generation lock is poisoned".into()))?;
5916 if let Some(tail) = state.tail.as_ref() {
5917 let collecting = matches!(
5918 *tail.phase.lock().map_err(|_| {
5919 NodeError::Invariant("read barrier round lock is poisoned".into())
5920 })?,
5921 ReadBarrierRoundPhase::Collecting
5922 );
5923 if collecting {
5924 return Ok(ReadBarrierParticipant {
5925 round: Arc::clone(tail),
5926 leader: false,
5927 });
5928 }
5929 }
5930
5931 let generation = state
5932 .next_generation
5933 .checked_add(1)
5934 .ok_or_else(|| NodeError::Invariant("read barrier generation is exhausted".into()))?;
5935 state.next_generation = generation;
5936 let collection_deadline = Instant::now()
5937 .checked_add(self.collection_window)
5938 .unwrap_or_else(Instant::now);
5939 let round = Arc::new(ReadBarrierRound {
5940 #[cfg(test)]
5941 generation,
5942 collection_deadline,
5943 predecessor: Mutex::new(state.tail.clone()),
5944 phase: Mutex::new(ReadBarrierRoundPhase::Collecting),
5945 changed: Condvar::new(),
5946 });
5947 state.tail = Some(Arc::clone(&round));
5948 Ok(ReadBarrierParticipant {
5949 round,
5950 leader: true,
5951 })
5952 }
5953
5954 fn cancel_waiters(&self) {
5955 let mut round = self.state.lock().ok().and_then(|state| state.tail.clone());
5956 while let Some(current) = round {
5957 if let Ok(_phase) = current.phase.lock() {
5958 current.changed.notify_all();
5959 }
5960 round = current
5961 .predecessor
5962 .lock()
5963 .ok()
5964 .and_then(|predecessor| predecessor.clone());
5965 }
5966 }
5967}
5968
5969impl ReadBarrierRound {
5970 fn wait_complete(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
5971 let mut phase = self
5972 .phase
5973 .lock()
5974 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5975 loop {
5976 if matches!(*phase, ReadBarrierRoundPhase::Complete(_)) {
5977 return Ok(());
5978 }
5979 if cancelled.load(Ordering::Acquire) {
5980 return Err(NodeError::Unavailable(
5981 "read barrier cancelled during shutdown".into(),
5982 ));
5983 }
5984 phase = self
5985 .changed
5986 .wait(phase)
5987 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5988 }
5989 }
5990
5991 fn result(&self, cancelled: &AtomicBool) -> Result<LogAnchor, NodeError> {
5992 let mut phase = self
5993 .phase
5994 .lock()
5995 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5996 loop {
5997 if let ReadBarrierRoundPhase::Complete(result) = &*phase {
5998 return result.clone();
5999 }
6000 if cancelled.load(Ordering::Acquire) {
6001 return Err(NodeError::Unavailable(
6002 "read barrier cancelled during shutdown".into(),
6003 ));
6004 }
6005 phase = self
6006 .changed
6007 .wait(phase)
6008 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
6009 }
6010 }
6011
6012 fn complete(&self, result: Result<LogAnchor, NodeError>) {
6013 if let Ok(mut phase) = self.phase.lock() {
6014 if !matches!(*phase, ReadBarrierRoundPhase::Complete(_)) {
6015 *phase = ReadBarrierRoundPhase::Complete(result);
6016 }
6017 self.changed.notify_all();
6018 } else {
6019 self.changed.notify_all();
6020 }
6021 }
6022}
6023
6024impl ReadBarrierParticipant {
6025 #[cfg(test)]
6026 fn generation(&self) -> u64 {
6027 self.round.generation
6028 }
6029
6030 #[cfg(test)]
6031 fn is_leader(&self) -> bool {
6032 self.leader
6033 }
6034
6035 fn publication(&self) -> Option<ReadBarrierPublication> {
6036 self.leader.then(|| ReadBarrierPublication {
6037 round: Arc::clone(&self.round),
6038 published: false,
6039 })
6040 }
6041
6042 fn wait(&self, cancelled: &AtomicBool) -> Result<LogAnchor, NodeError> {
6043 self.round.result(cancelled)
6044 }
6045}
6046
6047impl ReadBarrierPublication {
6048 fn wait_turn(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
6049 let predecessor = self
6050 .round
6051 .predecessor
6052 .lock()
6053 .map_err(|_| NodeError::Invariant("read barrier predecessor lock is poisoned".into()))?
6054 .clone();
6055 if let Some(predecessor) = predecessor {
6056 predecessor.wait_complete(cancelled)?;
6057 self.round
6058 .predecessor
6059 .lock()
6060 .map_err(|_| {
6061 NodeError::Invariant("read barrier predecessor lock is poisoned".into())
6062 })?
6063 .take();
6064 }
6065
6066 let mut phase = self
6067 .round
6068 .phase
6069 .lock()
6070 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
6071 loop {
6072 if cancelled.load(Ordering::Acquire) {
6073 return Err(NodeError::Unavailable(
6074 "read barrier cancelled during shutdown".into(),
6075 ));
6076 }
6077 if !matches!(*phase, ReadBarrierRoundPhase::Collecting) {
6078 return Err(NodeError::Invariant(
6079 "read barrier leader left the collecting phase early".into(),
6080 ));
6081 }
6082 let now = Instant::now();
6083 if now >= self.round.collection_deadline {
6084 return Ok(());
6085 }
6086 let wait = self
6087 .round
6088 .collection_deadline
6089 .saturating_duration_since(now);
6090 let (next, _) =
6091 self.round.changed.wait_timeout(phase, wait).map_err(|_| {
6092 NodeError::Invariant("read barrier round lock is poisoned".into())
6093 })?;
6094 phase = next;
6095 }
6096 }
6097
6098 fn start(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
6099 let mut phase = self
6100 .round
6101 .phase
6102 .lock()
6103 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
6104 if cancelled.load(Ordering::Acquire) {
6105 return Err(NodeError::Unavailable(
6106 "read barrier cancelled during shutdown".into(),
6107 ));
6108 }
6109 if !matches!(*phase, ReadBarrierRoundPhase::Collecting) {
6110 return Err(NodeError::Invariant(
6111 "read barrier generation cannot start twice".into(),
6112 ));
6113 }
6114 *phase = ReadBarrierRoundPhase::Running;
6115 Ok(())
6116 }
6117
6118 fn publish(&mut self, result: Result<LogAnchor, NodeError>) {
6119 self.round.complete(result);
6120 self.published = true;
6121 }
6122}
6123
6124impl Drop for ReadBarrierPublication {
6125 fn drop(&mut self) {
6126 if !self.published {
6127 self.round.complete(Err(NodeError::Unavailable(
6128 "read barrier generation leader terminated".into(),
6129 )));
6130 }
6131 }
6132}
6133
6134#[cfg(all(test, feature = "kv"))]
6135type KvGroupCommitAfterExecuteHook = Arc<dyn Fn(&NodeRuntime) + Send + Sync>;
6136
6137pub struct NodeRuntime {
6138 config: NodeConfig,
6139 consensus: Arc<ThreeNodeConsensus>,
6140 log_store: FileLogStore,
6141 materializer: Mutex<Materializer>,
6142 commit: Mutex<()>,
6143 #[cfg(feature = "sql")]
6144 sql_group_commit: SqlGroupCommitQueue,
6145 #[cfg(feature = "kv")]
6146 kv_group_commit: KvGroupCommitQueue,
6147 read_barriers: ReadBarrierRounds,
6148 checkpointing: AtomicBool,
6149 operation_cancelled: AtomicBool,
6150 operation_cancelled_notify: tokio::sync::Notify,
6151 ready: AtomicBool,
6152 fatal: AtomicBool,
6153 fatal_reason: Mutex<Option<String>>,
6154 #[cfg(test)]
6155 materialized_tip_checks: AtomicUsize,
6156 #[cfg(all(test, feature = "sql"))]
6157 sql_group_commit_before_execute_hook: Option<Arc<dyn Fn() + Send + Sync>>,
6158 #[cfg(all(test, feature = "kv"))]
6159 kv_group_commit_before_execute_hook: Option<Arc<dyn Fn() + Send + Sync>>,
6160 #[cfg(all(test, feature = "kv"))]
6161 kv_group_commit_after_execute_hook: Option<KvGroupCommitAfterExecuteHook>,
6162 _data_root_lock: fs::File,
6163}
6164
6165#[cfg(feature = "sql")]
6166struct ExecutedPayload {
6167 response: WriteResponse,
6168 sql_result: Option<SqlCommandResult>,
6169}
6170
6171#[cfg(feature = "sql")]
6172trait SqlWritePhaseProfile {
6173 type Mark;
6174
6175 fn mark(&self) -> Self::Mark;
6176 fn commit_lock_acquired(&mut self);
6177 fn add_precheck_classification(&mut self, mark: Self::Mark);
6178 fn add_qwal_prepare(&mut self, mark: Self::Mark);
6179 fn add_consensus_propose(&mut self, mark: Self::Mark);
6180 fn add_local_qlog_mirror_append(&mut self, mark: Self::Mark);
6181 fn add_sql_materializer_apply(&mut self, mark: Self::Mark);
6182 fn record_success(&mut self, batch_member_count: usize);
6183}
6184
6185#[cfg(feature = "sql")]
6186struct DisabledSqlWritePhaseProfile;
6187
6188#[cfg(feature = "sql")]
6189impl SqlWritePhaseProfile for DisabledSqlWritePhaseProfile {
6190 type Mark = ();
6191
6192 #[inline]
6193 fn mark(&self) {}
6194
6195 #[inline]
6196 fn commit_lock_acquired(&mut self) {}
6197
6198 #[inline]
6199 fn add_precheck_classification(&mut self, (): ()) {}
6200
6201 #[inline]
6202 fn add_qwal_prepare(&mut self, (): ()) {}
6203
6204 #[inline]
6205 fn add_consensus_propose(&mut self, (): ()) {}
6206
6207 #[inline]
6208 fn add_local_qlog_mirror_append(&mut self, (): ()) {}
6209
6210 #[inline]
6211 fn add_sql_materializer_apply(&mut self, (): ()) {}
6212
6213 #[inline]
6214 fn record_success(&mut self, _batch_member_count: usize) {}
6215}
6216
6217#[cfg(feature = "sql")]
6218struct EnabledSqlWritePhaseProfile {
6219 observer: SqlWriteProfiler,
6220 service_started: Instant,
6221 commit_lock_wait: Duration,
6222 precheck_classification: Duration,
6223 qwal_prepare: Duration,
6224 consensus_propose: Duration,
6225 local_qlog_mirror_append: Duration,
6226 sql_materializer_apply: Duration,
6227}
6228
6229#[cfg(feature = "sql")]
6230impl EnabledSqlWritePhaseProfile {
6231 fn new(observer: SqlWriteProfiler) -> Self {
6232 Self {
6233 observer,
6234 service_started: Instant::now(),
6235 commit_lock_wait: Duration::ZERO,
6236 precheck_classification: Duration::ZERO,
6237 qwal_prepare: Duration::ZERO,
6238 consensus_propose: Duration::ZERO,
6239 local_qlog_mirror_append: Duration::ZERO,
6240 sql_materializer_apply: Duration::ZERO,
6241 }
6242 }
6243
6244 fn reset(&mut self) {
6245 self.service_started = Instant::now();
6246 self.commit_lock_wait = Duration::ZERO;
6247 self.precheck_classification = Duration::ZERO;
6248 self.qwal_prepare = Duration::ZERO;
6249 self.consensus_propose = Duration::ZERO;
6250 self.local_qlog_mirror_append = Duration::ZERO;
6251 self.sql_materializer_apply = Duration::ZERO;
6252 }
6253}
6254
6255#[cfg(feature = "sql")]
6256impl SqlWritePhaseProfile for EnabledSqlWritePhaseProfile {
6257 type Mark = Instant;
6258
6259 #[inline]
6260 fn mark(&self) -> Instant {
6261 Instant::now()
6262 }
6263
6264 fn commit_lock_acquired(&mut self) {
6265 self.commit_lock_wait = self.service_started.elapsed();
6266 }
6267
6268 fn add_precheck_classification(&mut self, mark: Instant) {
6269 self.precheck_classification += mark.elapsed();
6270 }
6271
6272 fn add_qwal_prepare(&mut self, mark: Instant) {
6273 self.qwal_prepare += mark.elapsed();
6274 }
6275
6276 fn add_consensus_propose(&mut self, mark: Instant) {
6277 self.consensus_propose += mark.elapsed();
6278 }
6279
6280 fn add_local_qlog_mirror_append(&mut self, mark: Instant) {
6281 self.local_qlog_mirror_append += mark.elapsed();
6282 }
6283
6284 fn add_sql_materializer_apply(&mut self, mark: Instant) {
6285 self.sql_materializer_apply += mark.elapsed();
6286 }
6287
6288 fn record_success(&mut self, batch_member_count: usize) {
6289 let total_service_us = duration_as_u64_micros(self.service_started.elapsed());
6290 let commit_lock_wait_us = duration_as_u64_micros(self.commit_lock_wait);
6291 let precheck_classification_us = duration_as_u64_micros(self.precheck_classification);
6292 let qwal_prepare_us = duration_as_u64_micros(self.qwal_prepare);
6293 let consensus_propose_us = duration_as_u64_micros(self.consensus_propose);
6294 let local_qlog_mirror_append_us = duration_as_u64_micros(self.local_qlog_mirror_append);
6295 let sql_materializer_apply_us = duration_as_u64_micros(self.sql_materializer_apply);
6296 let named_us = commit_lock_wait_us
6297 .saturating_add(precheck_classification_us)
6298 .saturating_add(qwal_prepare_us)
6299 .saturating_add(consensus_propose_us)
6300 .saturating_add(local_qlog_mirror_append_us)
6301 .saturating_add(sql_materializer_apply_us);
6302 self.observer.record(SqlWriteProfileSample {
6303 batch_member_count,
6304 commit_lock_wait_us,
6305 precheck_classification_us,
6306 qwal_prepare_us,
6307 consensus_propose_us,
6308 local_qlog_mirror_append_us,
6309 sql_materializer_apply_us,
6310 response_other_total_us: total_service_us.saturating_sub(named_us),
6311 total_service_us,
6312 });
6313 self.reset();
6314 }
6315}
6316
6317#[cfg(feature = "sql")]
6318fn duration_as_u64_micros(duration: Duration) -> u64 {
6319 u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
6320}
6321
6322#[cfg(feature = "sql")]
6323type CheckedSqlRequest = Result<Option<(RequestOutcome, SqlCommandResult)>, NodeError>;
6324
6325#[cfg(feature = "sql")]
6326#[derive(Clone, Debug, Eq, PartialEq)]
6327pub struct VerifiedSnapshotPublication {
6328 anchor: RecoveryAnchor,
6329}
6330
6331impl fmt::Debug for NodeRuntime {
6332 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6333 f.debug_struct("NodeRuntime")
6334 .field("config", &self.config)
6335 .field("ready", &self.ready.load(Ordering::Acquire))
6336 .field("fatal", &self.fatal.load(Ordering::Acquire))
6337 .finish_non_exhaustive()
6338 }
6339}
6340
6341impl NodeRuntime {
6342 pub fn open(
6343 config: NodeConfig,
6344 consensus: Arc<ThreeNodeConsensus>,
6345 peer_candidates: &[&dyn LogPeer],
6346 ) -> Result<Self, NodeError> {
6347 Self::open_cancellable(config, consensus, peer_candidates, &StartupIoContext::new())
6348 }
6349
6350 pub fn open_cancellable(
6351 config: NodeConfig,
6352 consensus: Arc<ThreeNodeConsensus>,
6353 peer_candidates: &[&dyn LogPeer],
6354 startup: &StartupIoContext,
6355 ) -> Result<Self, NodeError> {
6356 startup.check("runtime configuration validation")?;
6357 if config.ack_mode == AckMode::DrStrong {
6358 return Err(NodeError::UnsupportedAckMode(AckMode::DrStrong));
6359 }
6360 Materializer::ensure_profile_available(config.execution_profile())?;
6361 startup.check("runtime data directory creation")?;
6362 fs::create_dir_all(&config.data_dir)
6363 .map_err(|error| NodeError::Storage(error.to_string()))?;
6364 startup.check("runtime data lock acquisition")?;
6365 let lock_path = config.data_dir.join(".node.lock");
6366 let data_root_lock = fs::OpenOptions::new()
6367 .read(true)
6368 .write(true)
6369 .create(true)
6370 .truncate(false)
6371 .open(&lock_path)
6372 .map_err(|error| NodeError::Storage(error.to_string()))?;
6373 match data_root_lock.try_lock() {
6374 Ok(()) => {}
6375 Err(fs::TryLockError::WouldBlock) => {
6376 return Err(NodeError::DataRootLocked(config.data_dir.clone()));
6377 }
6378 Err(fs::TryLockError::Error(error)) => {
6379 return Err(NodeError::Storage(error.to_string()));
6380 }
6381 }
6382
6383 startup.check("qlog open and recovery")?;
6384 let log_store = FileLogStore::open_with_configuration(
6385 config.data_dir.join("consensus/log"),
6386 &config.cluster_id,
6387 config.epoch,
6388 config.log_initial_configuration.clone(),
6389 )
6390 .map_err(|error| NodeError::Storage(error.to_string()))?;
6391 let persisted_configuration = log_store
6392 .configuration_state()
6393 .map_err(|error| NodeError::Storage(error.to_string()))?;
6394 let recovery_anchor = log_store
6395 .logical_state()
6396 .map_err(|error| NodeError::Storage(error.to_string()))?
6397 .anchor;
6398 startup.check("materializer open and reconciliation")?;
6399 let materializer =
6400 Materializer::open(&config, &persisted_configuration, recovery_anchor.as_ref())?;
6401 reconcile_local_storage(&config, &log_store, &materializer)?;
6402 startup.check("peer recovery")?;
6403 recover_peer_candidates(
6404 &config,
6405 consensus.as_ref(),
6406 &log_store,
6407 &materializer,
6408 peer_candidates,
6409 startup,
6410 )?;
6411 recover_startup_decisions(
6412 &config,
6413 consensus.as_ref(),
6414 &log_store,
6415 &materializer,
6416 startup,
6417 )?;
6418 startup.check("runtime readiness publication")?;
6419
6420 Ok(Self {
6421 #[cfg(feature = "sql")]
6422 sql_group_commit: SqlGroupCommitQueue::new(config.sql_group_commit_queue_capacity),
6423 #[cfg(feature = "kv")]
6424 kv_group_commit: KvGroupCommitQueue::new(),
6425 config,
6426 consensus,
6427 log_store,
6428 materializer: Mutex::new(materializer),
6429 commit: Mutex::new(()),
6430 read_barriers: ReadBarrierRounds::new(READ_BARRIER_COALESCE_WINDOW),
6431 checkpointing: AtomicBool::new(false),
6432 operation_cancelled: AtomicBool::new(false),
6433 operation_cancelled_notify: tokio::sync::Notify::new(),
6434 ready: AtomicBool::new(true),
6435 fatal: AtomicBool::new(false),
6436 fatal_reason: Mutex::new(None),
6437 #[cfg(test)]
6438 materialized_tip_checks: AtomicUsize::new(0),
6439 #[cfg(all(test, feature = "sql"))]
6440 sql_group_commit_before_execute_hook: None,
6441 #[cfg(all(test, feature = "kv"))]
6442 kv_group_commit_before_execute_hook: None,
6443 #[cfg(all(test, feature = "kv"))]
6444 kv_group_commit_after_execute_hook: None,
6445 _data_root_lock: data_root_lock,
6446 })
6447 }
6448
6449 #[cfg(feature = "sql")]
6450 pub fn write(
6451 &self,
6452 request_id: &str,
6453 key: &str,
6454 value: &str,
6455 ) -> Result<WriteResponse, NodeError> {
6456 let payload = canonical_put(request_id, key, value)?;
6457 let _commit = self.lock_commit()?;
6458 self.execute_put_payload_locked(request_id, key, value, payload)
6459 .map(|outcome| outcome.response)
6460 }
6461
6462 #[cfg(feature = "graph")]
6463 pub fn mutate_graph(&self, command: GraphCommandV1) -> Result<GraphMutationOutcome, NodeError> {
6464 let payload = encode_replicated_graph_command(&command)
6465 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6466 if payload.len() > MAX_COMMAND_BYTES {
6467 return Err(NodeError::InvalidRequest(format!(
6468 "command exceeds {MAX_COMMAND_BYTES} bytes"
6469 )));
6470 }
6471 let _commit = self.lock_commit()?;
6472 self.mutate_graph_payload_locked(&command, payload)
6473 }
6474
6475 #[cfg(feature = "graph")]
6481 #[cfg_attr(
6482 all(not(feature = "sql"), not(feature = "kv")),
6483 allow(unreachable_patterns)
6484 )]
6485 pub fn mutate_graph_batch(
6486 &self,
6487 commands: Vec<GraphCommandV1>,
6488 ) -> Result<Vec<Result<GraphMutationOutcome, NodeError>>, NodeError> {
6489 self.require_execution_profile(ExecutionProfile::Graph)?;
6490 validate_typed_batch_len(commands.len())?;
6491 let mut members = Vec::with_capacity(commands.len());
6492 for command in commands {
6493 let payload = encode_replicated_graph_command(&command)
6494 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6495 validate_command_size(&payload)?;
6496 members.push(RuntimeBatchMember {
6497 #[cfg(feature = "sql")]
6498 request_id: command.request_id().to_owned(),
6499 payload,
6500 operation: QueuedOperation::Graph(command),
6501 });
6502 }
6503 Ok(self
6504 .execute_client_batch(members)
6505 .into_iter()
6506 .map(|result| {
6507 result.and_then(|response| match response {
6508 ClientWriteResponse::Graph(outcome) => Ok(outcome),
6509 _ => Err(NodeError::Invariant(
6510 "graph batch returned a response for another profile".into(),
6511 )),
6512 })
6513 })
6514 .collect())
6515 }
6516
6517 #[cfg(feature = "graph")]
6518 fn mutate_graph_payload_locked(
6519 &self,
6520 command: &GraphCommandV1,
6521 payload: Vec<u8>,
6522 ) -> Result<GraphMutationOutcome, NodeError> {
6523 self.ensure_ready()?;
6524 self.ensure_writes_active()?;
6525 if let Some(record) = self.check_graph_request(command.request_id(), &payload)? {
6526 return Ok(GraphMutationOutcome::from_record(record));
6527 }
6528 loop {
6529 let (last_index, last_hash) = self.ensure_materialized_tip()?;
6530 let slot = last_index.checked_add(1).ok_or_else(|| {
6531 self.latch(NodeError::Invariant("qlog index is exhausted".into()))
6532 })?;
6533 let entry = self
6534 .consensus
6535 .propose_at_cancellable(
6536 slot,
6537 last_hash,
6538 Command::new(CommandKind::Deterministic, payload.clone()),
6539 &self.operation_cancelled,
6540 )
6541 .map_err(|error| self.map_consensus_error(error))?;
6542 self.persist_entry(&entry, slot, last_hash)?;
6543 if let Some(record) = self.check_graph_request(command.request_id(), &payload)? {
6544 return Ok(GraphMutationOutcome::from_record(record));
6545 }
6546 if entry.entry_type == EntryType::Command && entry.payload == payload {
6547 return Err(self.latch(NodeError::Invariant(
6548 "committed graph request was not recorded by Ladybug".into(),
6549 )));
6550 }
6551 }
6552 }
6553
6554 #[cfg(feature = "graph")]
6555 pub fn get_graph_document(
6556 &self,
6557 id: &str,
6558 consistency: ReadConsistency,
6559 ) -> Result<GraphReadResponse, NodeError> {
6560 match consistency {
6561 ReadConsistency::Local => self.get_graph_document_local(id, None),
6562 ReadConsistency::AppliedIndex(required) => {
6563 self.get_graph_document_local(id, Some(required))
6564 }
6565 ReadConsistency::ReadBarrier => {
6566 let anchor = self.establish_read_barrier()?;
6567 self.validate_read_barrier_before_snapshot(anchor)?;
6568 let response = self.get_graph_document_local(id, Some(anchor.index()))?;
6569 self.validate_read_barrier_snapshot(
6570 anchor,
6571 LogAnchor::new(response.applied_index, response.hash),
6572 )?;
6573 Ok(response)
6574 }
6575 }
6576 }
6577
6578 #[cfg(feature = "graph")]
6579 pub fn query_graph(
6580 &self,
6581 statement: &str,
6582 parameters: &BTreeMap<String, GraphParameterValue>,
6583 consistency: ReadConsistency,
6584 max_rows: u32,
6585 ) -> Result<GraphQueryResult, NodeError> {
6586 if max_rows == 0 || max_rows > MAX_GRAPH_MAX_ROWS {
6587 return Err(NodeError::InvalidRequest(format!(
6588 "max_rows must be between 1 and {MAX_GRAPH_MAX_ROWS}"
6589 )));
6590 }
6591 match consistency {
6592 ReadConsistency::Local => self.query_graph_local(statement, parameters, None, max_rows),
6593 ReadConsistency::AppliedIndex(required) => {
6594 self.query_graph_local(statement, parameters, Some(required), max_rows)
6595 }
6596 ReadConsistency::ReadBarrier => {
6597 let anchor = self.establish_read_barrier()?;
6598 self.validate_read_barrier_before_snapshot(anchor)?;
6599 let result =
6600 self.query_graph_local(statement, parameters, Some(anchor.index()), max_rows)?;
6601 self.validate_read_barrier_snapshot(
6602 anchor,
6603 LogAnchor::new(result.applied_index, result.hash),
6604 )?;
6605 Ok(result)
6606 }
6607 }
6608 }
6609
6610 #[cfg(feature = "kv")]
6611 pub fn mutate_kv(&self, command: KvCommandV1) -> Result<KvMutationOutcome, NodeError> {
6612 self.mutate_kv_batch(vec![command])?
6613 .into_iter()
6614 .next()
6615 .expect("one-member KV batch returns one result")
6616 }
6617
6618 #[cfg(feature = "kv")]
6624 #[cfg_attr(
6625 all(not(feature = "sql"), not(feature = "graph")),
6626 allow(unreachable_patterns)
6627 )]
6628 pub fn mutate_kv_batch(
6629 &self,
6630 commands: Vec<KvCommandV1>,
6631 ) -> Result<Vec<Result<KvMutationOutcome, NodeError>>, NodeError> {
6632 self.require_execution_profile(ExecutionProfile::Kv)?;
6633 validate_kv_batch_len(commands.len())?;
6634 let mut members = Vec::with_capacity(commands.len());
6635 for command in commands {
6636 let payload = encode_replicated_kv_command(&command)
6637 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6638 validate_command_size(&payload)?;
6639 members.push(RuntimeBatchMember {
6640 #[cfg(feature = "sql")]
6641 request_id: command.request_id().to_owned(),
6642 payload,
6643 operation: QueuedOperation::Kv(command),
6644 });
6645 }
6646 Ok(self
6647 .execute_kv_group_commit(members)?
6648 .into_iter()
6649 .map(|result| {
6650 result.and_then(|response| match response {
6651 ClientWriteResponse::Kv(outcome) => Ok(outcome),
6652 _ => Err(NodeError::Invariant(
6653 "KV batch returned a response for another profile".into(),
6654 )),
6655 })
6656 })
6657 .collect())
6658 }
6659
6660 #[cfg(feature = "kv")]
6661 fn mutate_kv_payload_locked(
6662 &self,
6663 command: &KvCommandV1,
6664 payload: Vec<u8>,
6665 ) -> Result<KvMutationOutcome, NodeError> {
6666 self.ensure_ready()?;
6667 self.ensure_writes_active()?;
6668 if let Some(record) = self.check_kv_request(command.request_id(), &payload)? {
6669 return Ok(KvMutationOutcome::from_record(record));
6670 }
6671 loop {
6672 let (last_index, last_hash) = self.ensure_materialized_tip()?;
6673 let slot = last_index.checked_add(1).ok_or_else(|| {
6674 self.latch(NodeError::Invariant("qlog index is exhausted".into()))
6675 })?;
6676 let entry = self
6677 .consensus
6678 .propose_at_cancellable(
6679 slot,
6680 last_hash,
6681 Command::new(CommandKind::Deterministic, payload.clone()),
6682 &self.operation_cancelled,
6683 )
6684 .map_err(|error| self.map_consensus_error(error))?;
6685 self.persist_entry(&entry, slot, last_hash)?;
6686 if let Some(record) = self.check_kv_request(command.request_id(), &payload)? {
6687 return Ok(KvMutationOutcome::from_record(record));
6688 }
6689 if entry.entry_type == EntryType::Command && entry.payload == payload {
6690 return Err(self.latch(NodeError::Invariant(
6691 "committed KV request was not recorded by redb".into(),
6692 )));
6693 }
6694 }
6695 }
6696
6697 #[cfg(feature = "kv")]
6698 pub fn get_kv(
6699 &self,
6700 key: &[u8],
6701 consistency: ReadConsistency,
6702 ) -> Result<KvReadResponse, NodeError> {
6703 match consistency {
6704 ReadConsistency::Local => self.get_kv_local(key, None),
6705 ReadConsistency::AppliedIndex(required) => self.get_kv_local(key, Some(required)),
6706 ReadConsistency::ReadBarrier => {
6707 let anchor = self.establish_read_barrier()?;
6708 self.validate_read_barrier_before_snapshot(anchor)?;
6709 let response = self.get_kv_local(key, Some(anchor.index()))?;
6710 self.validate_read_barrier_snapshot(
6711 anchor,
6712 LogAnchor::new(response.applied_index, response.hash),
6713 )?;
6714 Ok(response)
6715 }
6716 }
6717 }
6718
6719 #[cfg(feature = "kv")]
6720 pub fn scan_kv_range(
6721 &self,
6722 start: &[u8],
6723 end: Option<&[u8]>,
6724 limit: usize,
6725 cursor: Option<&[u8]>,
6726 consistency: ReadConsistency,
6727 ) -> Result<KvScanResult, NodeError> {
6728 match consistency {
6729 ReadConsistency::Local => self.scan_kv_range_local(start, end, limit, cursor, None),
6730 ReadConsistency::AppliedIndex(required) => {
6731 self.scan_kv_range_local(start, end, limit, cursor, Some(required))
6732 }
6733 ReadConsistency::ReadBarrier => {
6734 let anchor = self.establish_read_barrier()?;
6735 self.validate_read_barrier_before_snapshot(anchor)?;
6736 let result =
6737 self.scan_kv_range_local(start, end, limit, cursor, Some(anchor.index()))?;
6738 self.validate_read_barrier_snapshot(
6739 anchor,
6740 LogAnchor::new(result.tip().applied_index(), result.tip().applied_hash()),
6741 )?;
6742 Ok(result)
6743 }
6744 }
6745 }
6746
6747 #[cfg(feature = "kv")]
6748 pub fn scan_kv_prefix(
6749 &self,
6750 prefix: &[u8],
6751 limit: usize,
6752 cursor: Option<&[u8]>,
6753 consistency: ReadConsistency,
6754 ) -> Result<KvScanResult, NodeError> {
6755 match consistency {
6756 ReadConsistency::Local => self.scan_kv_prefix_local(prefix, limit, cursor, None),
6757 ReadConsistency::AppliedIndex(required) => {
6758 self.scan_kv_prefix_local(prefix, limit, cursor, Some(required))
6759 }
6760 ReadConsistency::ReadBarrier => {
6761 let anchor = self.establish_read_barrier()?;
6762 self.validate_read_barrier_before_snapshot(anchor)?;
6763 let result =
6764 self.scan_kv_prefix_local(prefix, limit, cursor, Some(anchor.index()))?;
6765 self.validate_read_barrier_snapshot(
6766 anchor,
6767 LogAnchor::new(result.tip().applied_index(), result.tip().applied_hash()),
6768 )?;
6769 Ok(result)
6770 }
6771 }
6772 }
6773
6774 #[cfg(feature = "sql")]
6775 pub fn execute_sql(&self, command: SqlCommand) -> Result<WriteResponse, NodeError> {
6776 self.execute_sql_with_results(command)
6777 .map(|response| WriteResponse {
6778 applied_index: response.applied_index,
6779 hash: response.hash,
6780 })
6781 }
6782
6783 #[cfg(feature = "sql")]
6791 pub fn execute_sql_batch(
6792 &self,
6793 commands: Vec<SqlCommand>,
6794 ) -> Result<Vec<Result<SqlExecuteResponse, NodeError>>, NodeError> {
6795 self.require_execution_profile(ExecutionProfile::Sqlite)?;
6796 validate_sql_batch_len(commands.len())?;
6797 let mut members = Vec::with_capacity(commands.len());
6798 let mut aggregate_encoded_bytes = 0_usize;
6799 for command in commands {
6800 validate_field(
6801 "request_id",
6802 &command.request_id,
6803 MAX_REQUEST_ID_BYTES,
6804 false,
6805 )?;
6806 let payload = encode_sql_command_with_index(&command)?;
6807 validate_command_size(&payload)?;
6808 aggregate_encoded_bytes = aggregate_encoded_bytes.saturating_add(payload.len());
6809 if aggregate_encoded_bytes > MAX_COMMAND_BYTES {
6810 return Err(NodeError::ResourceExhausted(format!(
6811 "SQL write batch exceeds {MAX_COMMAND_BYTES} aggregate encoded bytes"
6812 )));
6813 }
6814 members.push(RuntimeBatchMember {
6815 request_id: command.request_id.clone(),
6816 payload,
6817 operation: QueuedOperation::Sql(command),
6818 });
6819 }
6820 let single_statement_batch = members.iter().all(|member| {
6821 matches!(
6822 &member.operation,
6823 QueuedOperation::Sql(command) if command.statements.len() == 1
6824 )
6825 });
6826 let results = if single_statement_batch {
6827 self.execute_sql_group_commit(members)?
6828 } else {
6829 self.execute_client_batch(members)
6830 };
6831 Ok(results
6832 .into_iter()
6833 .map(|result| {
6834 result.and_then(|response| match response {
6835 ClientWriteResponse::Sql(response) => Ok(response),
6836 _ => Err(NodeError::Invariant(
6837 "SQL batch returned a response for another profile".into(),
6838 )),
6839 })
6840 })
6841 .collect())
6842 }
6843
6844 #[cfg(feature = "sql")]
6845 fn execute_sql_with_results(
6846 &self,
6847 command: SqlCommand,
6848 ) -> Result<SqlExecuteResponse, NodeError> {
6849 self.execute_sql_batch(vec![command])?
6850 .into_iter()
6851 .next()
6852 .expect("one-member SQL batch returns one result")
6853 }
6854
6855 #[cfg(feature = "sql")]
6856 fn execute_sql_group_commit(&self, members: Vec<RuntimeBatchMember>) -> SqlGroupCommitResult {
6857 let (job, leader) = self
6858 .sql_group_commit
6859 .enqueue(members, &self.operation_cancelled)?;
6860 if leader {
6861 if let Some(observer) = self.config.sql_write_profiler.clone() {
6862 self.run_sql_group_commit_leader(&mut EnabledSqlWritePhaseProfile::new(observer));
6863 } else {
6864 self.run_sql_group_commit_leader(&mut DisabledSqlWritePhaseProfile);
6865 }
6866 }
6867 job.wait(&self.operation_cancelled)
6868 }
6869
6870 #[cfg(feature = "sql")]
6871 fn run_sql_group_commit_leader<P: SqlWritePhaseProfile>(&self, profile: &mut P) {
6872 let _commit = match self.lock_commit() {
6873 Ok(commit) => commit,
6874 Err(error) => {
6875 self.sql_group_commit.fail_pending(error);
6876 return;
6877 }
6878 };
6879 profile.commit_lock_acquired();
6880
6881 loop {
6882 if !self
6883 .sql_group_commit
6884 .collect_until_full_or_timeout(self.config.writer_batch_window())
6885 {
6886 break;
6887 }
6888 let Some(jobs) = self.sql_group_commit.drain_next_group() else {
6889 break;
6890 };
6891 if let Err(error) = self
6892 .ensure_ready()
6893 .and_then(|_| self.ensure_writes_active())
6894 {
6895 for job in &jobs {
6896 job.publish(Err(error.clone()));
6897 }
6898 self.sql_group_commit.fail_pending(error);
6899 return;
6900 }
6901 let execution = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
6902 #[cfg(test)]
6903 if let Some(hook) = &self.sql_group_commit_before_execute_hook {
6904 hook();
6905 }
6906 self.execute_sql_group_locked(&jobs, profile)
6907 }));
6908 let grouped_results = match execution {
6909 Ok(Ok(grouped_results)) => grouped_results,
6910 Ok(Err(error)) => {
6911 let error = if matches!(error, NodeError::Unavailable(_)) {
6912 error
6913 } else {
6914 self.latch(error)
6915 };
6916 for job in &jobs {
6917 job.publish(Err(error.clone()));
6918 }
6919 self.sql_group_commit.fail_pending(error);
6920 return;
6921 }
6922 Err(_) => {
6923 let error =
6924 self.latch(NodeError::Fatal("SQL group commit leader panicked".into()));
6925 for job in &jobs {
6926 job.publish(Err(error.clone()));
6927 }
6928 self.sql_group_commit.fail_pending(error);
6929 return;
6930 }
6931 };
6932 for (job, results) in jobs.iter().zip(grouped_results) {
6933 job.publish(Ok(results));
6934 }
6935 }
6936 }
6937
6938 #[cfg(feature = "sql")]
6939 fn execute_sql_group_locked<P: SqlWritePhaseProfile>(
6940 &self,
6941 jobs: &[Arc<SqlGroupCommitJob>],
6942 profile: &mut P,
6943 ) -> Result<Vec<Vec<Result<ClientWriteResponse, NodeError>>>, NodeError> {
6944 if self.operation_cancelled.load(Ordering::Acquire) {
6945 return Err(NodeError::Unavailable(
6946 "SQL group commit cancelled during shutdown".into(),
6947 ));
6948 }
6949 let total_members = jobs.iter().map(|job| job.member_count).sum();
6950 if total_members == 0 || total_members > MAX_SQL_WRITE_BATCH_MEMBERS {
6951 return Err(NodeError::Invariant(format!(
6952 "SQL group commit drained {total_members} members outside 1..={MAX_SQL_WRITE_BATCH_MEMBERS}"
6953 )));
6954 }
6955 let mut members = Vec::with_capacity(total_members);
6956 for job in jobs {
6957 let job_members = job.take_members()?;
6958 if job_members.len() != job.member_count {
6959 return Err(NodeError::Invariant(
6960 "SQL group commit job member count changed while queued".into(),
6961 ));
6962 }
6963 members.extend(job_members);
6964 }
6965 let results = self.execute_sql_client_batch_locked(&members, profile);
6966 if self.operation_cancelled.load(Ordering::Acquire) {
6967 return Err(NodeError::Unavailable(
6968 "SQL group commit cancelled during shutdown".into(),
6969 ));
6970 }
6971 if self.is_fatal() {
6972 return Err(NodeError::Fatal(
6973 self.fatal_reason()
6974 .unwrap_or_else(|| "SQL group commit failed fatally".into()),
6975 ));
6976 }
6977 if results.len() != total_members {
6978 return Err(NodeError::Invariant(format!(
6979 "SQL group commit returned {} results for {total_members} members",
6980 results.len()
6981 )));
6982 }
6983 let mut results = results.into_iter();
6984 let grouped = jobs
6985 .iter()
6986 .map(|job| results.by_ref().take(job.member_count).collect::<Vec<_>>())
6987 .collect::<Vec<_>>();
6988 if results.next().is_some() || grouped.iter().map(Vec::len).sum::<usize>() != total_members
6989 {
6990 return Err(NodeError::Invariant(
6991 "SQL group commit result offsets were misaligned".into(),
6992 ));
6993 }
6994 Ok(grouped)
6995 }
6996
6997 #[cfg(feature = "sql")]
6998 fn execute_client_batch(
6999 &self,
7000 members: Vec<RuntimeBatchMember>,
7001 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7002 if self.config.execution_profile != ExecutionProfile::Sqlite {
7003 return self.execute_profile_client_batch(members);
7004 }
7005 let is_single_statement_sql = members.iter().all(|member| {
7006 matches!(
7007 &member.operation,
7008 QueuedOperation::Sql(command) if command.statements.len() == 1
7009 )
7010 });
7011 if is_single_statement_sql {
7012 if let Some(observer) = self.config.sql_write_profiler.clone() {
7013 let mut profile = EnabledSqlWritePhaseProfile::new(observer);
7014 let _commit = match self.lock_commit() {
7015 Ok(commit) => commit,
7016 Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7017 };
7018 profile.commit_lock_acquired();
7019 if let Err(error) = self
7020 .ensure_ready()
7021 .and_then(|_| self.ensure_writes_active())
7022 {
7023 return members.into_iter().map(|_| Err(error.clone())).collect();
7024 }
7025 return self.execute_sql_client_batch_locked(&members, &mut profile);
7026 }
7027 }
7028 let _commit = match self.lock_commit() {
7029 Ok(commit) => commit,
7030 Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7031 };
7032 if let Err(error) = self
7033 .ensure_ready()
7034 .and_then(|_| self.ensure_writes_active())
7035 {
7036 return members.into_iter().map(|_| Err(error.clone())).collect();
7037 }
7038
7039 if is_single_statement_sql {
7040 return self
7041 .execute_sql_client_batch_locked(&members, &mut DisabledSqlWritePhaseProfile);
7042 }
7043
7044 members
7045 .iter()
7046 .map(|member| self.execute_single_member_locked(member))
7047 .collect()
7048 }
7049
7050 #[cfg(feature = "sql")]
7051 fn execute_sql_client_batch_locked<P: SqlWritePhaseProfile>(
7052 &self,
7053 members: &[RuntimeBatchMember],
7054 profile: &mut P,
7055 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7056 let classification_mark = profile.mark();
7057 let mut results = vec![None; members.len()];
7058 let mut pending = Vec::new();
7059 let mut canonical_by_request: HashMap<String, Vec<usize>> = HashMap::new();
7060 let mut lookup_indices = Vec::with_capacity(members.len());
7061 let mut aliases = vec![None; members.len()];
7062 let mut blocked_by = vec![None; members.len()];
7063
7064 for (index, member) in members.iter().enumerate() {
7065 let QueuedOperation::Sql(command) = &member.operation else {
7066 unreachable!("SQL batch members were validated by the caller");
7067 };
7068 let canonicals = canonical_by_request
7069 .entry(command.request_id.clone())
7070 .or_default();
7071 if let Some(canonical) = canonicals
7072 .iter()
7073 .copied()
7074 .find(|canonical| members[*canonical].payload == member.payload)
7075 {
7076 aliases[index] = Some(canonical);
7077 continue;
7078 }
7079 blocked_by[index] = canonicals.last().copied();
7080 canonicals.push(index);
7081 lookup_indices.push(index);
7082 }
7083
7084 let preflight = self.check_sql_members_bulk(members, &lookup_indices);
7085 let preflight = match preflight {
7086 Ok(preflight) => preflight,
7087 Err(error) => {
7088 profile.add_precheck_classification(classification_mark);
7089 return members.iter().map(|_| Err(error.clone())).collect();
7090 }
7091 };
7092 for (index, lookup) in preflight {
7093 match lookup {
7094 Ok(Some((outcome, sql_result))) => {
7095 results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7096 write_response(outcome),
7097 Some(sql_result),
7098 ))));
7099 }
7100 Ok(None) => pending.push(index),
7101 Err(error) => results[index] = Some(Err(error)),
7102 }
7103 }
7104 profile.add_precheck_classification(classification_mark);
7105
7106 while !pending.is_empty() {
7107 let eligible = pending
7108 .iter()
7109 .copied()
7110 .filter(|index| {
7111 blocked_by[*index].is_none_or(|predecessor| results[predecessor].is_some())
7112 })
7113 .take(MAX_SQL_WRITE_BATCH_MEMBERS)
7114 .collect::<Vec<_>>();
7115 if eligible.is_empty() {
7116 let error = self.latch(NodeError::Invariant(
7117 "SQL writer batch has an unresolved duplicate dependency".into(),
7118 ));
7119 for index in pending.drain(..) {
7120 results[index] = Some(Err(error.clone()));
7121 }
7122 break;
7123 }
7124
7125 let (last_index, last_hash) = match self.ensure_materialized_tip() {
7126 Ok(tip) => tip,
7127 Err(error) => {
7128 for index in pending.drain(..) {
7129 results[index] = Some(Err(error.clone()));
7130 }
7131 break;
7132 }
7133 };
7134 let mut attempt_count = eligible.len();
7135 let (proposal_payload, prepared_results) = loop {
7136 let attempted = &eligible[..attempt_count];
7137 let batch_members = attempted
7138 .iter()
7139 .map(|index| {
7140 let QueuedOperation::Sql(command) = &members[*index].operation else {
7141 unreachable!("SQL batch members were validated above");
7142 };
7143 SqlBatchMember {
7144 command,
7145 request_payload: &members[*index].payload,
7146 }
7147 })
7148 .collect::<Vec<_>>();
7149 let preparation_mark = profile.mark();
7150 let preparation = self.lock_sqlite().and_then(|sqlite| {
7151 sqlite
7152 .prepare_sql_batch_effect(&batch_members, last_index, last_hash)
7153 .map_err(|error| self.map_sqlite_error(error))
7154 });
7155 profile.add_qwal_prepare(preparation_mark);
7156 let preparation = match preparation {
7157 Ok(preparation) => preparation,
7158 Err(NodeError::ResourceExhausted(message)) if attempt_count > 1 => {
7159 attempt_count = attempt_count.div_ceil(2);
7160 let _ = message;
7161 continue;
7162 }
7163 Err(NodeError::ResourceExhausted(message)) => {
7164 results[attempted[0]] = Some(Err(NodeError::ResourceExhausted(message)));
7165 break (None, Vec::new());
7166 }
7167 Err(error) => {
7168 for index in pending.drain(..) {
7169 results[index] = Some(Err(error.clone()));
7170 }
7171 break (None, Vec::new());
7172 }
7173 };
7174 if preparation.results.len() != attempted.len() {
7175 let error = self.latch(NodeError::Invariant(
7176 "SQLite batch preparation returned a misaligned result vector".into(),
7177 ));
7178 for index in pending.drain(..) {
7179 results[index] = Some(Err(error.clone()));
7180 }
7181 break (None, Vec::new());
7182 }
7183
7184 let mut proposed = Vec::new();
7185 for (index, member_result) in attempted.iter().copied().zip(preparation.results) {
7186 match member_result {
7187 Ok(result) => proposed.push((index, result)),
7188 Err(error) => {
7189 results[index] = Some(Err(self.map_sql_batch_member_error(error)))
7190 }
7191 }
7192 }
7193 match preparation.effect {
7194 Some(_) if proposed.is_empty() => {
7195 let error = self.latch(NodeError::Invariant(
7196 "SQLite prepared an effect without a successful SQL member".into(),
7197 ));
7198 for index in pending.drain(..) {
7199 results[index] = Some(Err(error.clone()));
7200 }
7201 break (None, Vec::new());
7202 }
7203 Some(payload) if !payload.starts_with(QWAL_V3_MAGIC) => {
7204 let error = self.latch(NodeError::Invariant(
7205 "SQLite materializer prepared a non-QWAL v3 SQL batch".into(),
7206 ));
7207 for index in pending.drain(..) {
7208 results[index] = Some(Err(error.clone()));
7209 }
7210 break (None, Vec::new());
7211 }
7212 Some(payload) if payload.len() <= MAX_COMMAND_BYTES => {
7213 break (Some(payload), proposed)
7214 }
7215 Some(_) if attempt_count > 1 => {
7216 for index in attempted {
7217 results[*index] = None;
7218 }
7219 attempt_count = attempt_count.div_ceil(2);
7220 }
7221 Some(_) => {
7222 results[attempted[0]] = Some(Err(NodeError::ResourceExhausted(format!(
7223 "SQL effect exceeds {MAX_COMMAND_BYTES} bytes"
7224 ))));
7225 break (None, Vec::new());
7226 }
7227 None if proposed.is_empty() => break (None, Vec::new()),
7228 None => {
7229 let error = self.latch(NodeError::Invariant(
7230 "SQLite omitted the effect for successful SQL members".into(),
7231 ));
7232 for index in pending.drain(..) {
7233 results[index] = Some(Err(error.clone()));
7234 }
7235 break (None, Vec::new());
7236 }
7237 }
7238 };
7239
7240 pending.retain(|index| results[*index].is_none());
7241 let Some(proposal_payload) = proposal_payload else {
7242 continue;
7243 };
7244 let slot = match last_index.checked_add(1) {
7245 Some(slot) => slot,
7246 None => {
7247 let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7248 for index in pending.drain(..) {
7249 results[index] = Some(Err(error.clone()));
7250 }
7251 break;
7252 }
7253 };
7254 let consensus_mark = profile.mark();
7255 let entry = self.consensus.propose_at_cancellable(
7256 slot,
7257 last_hash,
7258 Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7259 &self.operation_cancelled,
7260 );
7261 profile.add_consensus_propose(consensus_mark);
7262 let entry = match entry {
7263 Ok(entry) => entry,
7264 Err(error) => {
7265 let error = self.map_consensus_error(error);
7266 for index in pending.drain(..) {
7267 results[index] = Some(Err(error.clone()));
7268 }
7269 break;
7270 }
7271 };
7272 if let Err(error) = self.persist_sql_entry_profiled(&entry, slot, last_hash, profile) {
7273 for index in pending.drain(..) {
7274 results[index] = Some(Err(error.clone()));
7275 }
7276 break;
7277 }
7278
7279 let exact_winner =
7280 entry.entry_type == EntryType::Command && entry.payload == proposal_payload;
7281 let exact_winner_member_count = exact_winner.then_some(prepared_results.len());
7282 if exact_winner {
7283 for (index, sql_result) in prepared_results {
7284 results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7285 WriteResponse {
7286 applied_index: entry.index,
7287 hash: entry.hash,
7288 },
7289 Some(sql_result),
7290 ))));
7291 }
7292 pending.retain(|index| results[*index].is_none());
7293 }
7294
7295 let current_pending = std::mem::take(&mut pending);
7296 let classification_mark = profile.mark();
7297 let post_commit = self.check_sql_members_bulk(members, ¤t_pending);
7298 let post_commit = match post_commit {
7299 Ok(post_commit) => post_commit,
7300 Err(error) => {
7301 for index in current_pending {
7302 results[index] = Some(Err(error.clone()));
7303 }
7304 profile.add_precheck_classification(classification_mark);
7305 if let Some(batch_member_count) = exact_winner_member_count {
7306 profile.record_success(batch_member_count);
7307 }
7308 break;
7309 }
7310 };
7311 for (index, lookup) in post_commit {
7312 match lookup {
7313 Ok(Some((outcome, sql_result))) => {
7314 results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7315 write_response(outcome),
7316 Some(sql_result),
7317 ))));
7318 }
7319 Ok(None) => pending.push(index),
7320 Err(error) => results[index] = Some(Err(error)),
7321 }
7322 }
7323 profile.add_precheck_classification(classification_mark);
7324 if let Some(batch_member_count) = exact_winner_member_count {
7325 profile.record_success(batch_member_count);
7326 }
7327 }
7328
7329 for (index, canonical) in aliases.into_iter().enumerate() {
7330 if let Some(canonical) = canonical {
7331 results[index] = results[canonical].clone();
7332 }
7333 }
7334 results
7335 .into_iter()
7336 .map(|result| {
7337 result.unwrap_or_else(|| {
7338 Err(self.latch(NodeError::Invariant(
7339 "SQL writer batch omitted a request result".into(),
7340 )))
7341 })
7342 })
7343 .collect()
7344 }
7345
7346 #[cfg(feature = "sql")]
7347 fn check_sql_members_bulk(
7348 &self,
7349 members: &[RuntimeBatchMember],
7350 indices: &[usize],
7351 ) -> Result<Vec<(usize, CheckedSqlRequest)>, NodeError> {
7352 if indices.is_empty() {
7353 return Ok(Vec::new());
7354 }
7355 let mut rounds = Vec::<Vec<usize>>::new();
7356 let mut occurrence_by_request = HashMap::<String, usize>::new();
7357 for index in indices {
7358 let QueuedOperation::Sql(command) = &members[*index].operation else {
7359 return Err(self.latch(NodeError::Invariant(
7360 "non-SQL member reached SQL receipt precheck".into(),
7361 )));
7362 };
7363 let round = occurrence_by_request
7364 .entry(command.request_id.clone())
7365 .or_default();
7366 if rounds.len() == *round {
7367 rounds.push(Vec::new());
7368 }
7369 rounds[*round].push(*index);
7370 *round += 1;
7371 }
7372
7373 let sqlite = self.lock_sqlite()?;
7374 let mut checked = Vec::with_capacity(indices.len());
7375 for round in rounds {
7376 let requests = round
7377 .iter()
7378 .map(|index| {
7379 let QueuedOperation::Sql(command) = &members[*index].operation else {
7380 unreachable!("SQL receipt round contains only SQL members");
7381 };
7382 (
7383 command.request_id.as_str(),
7384 members[*index].payload.as_slice(),
7385 )
7386 })
7387 .collect::<Vec<_>>();
7388 let lookups = sqlite
7389 .check_sql_requests(&requests)
7390 .map_err(|error| self.map_sqlite_error(error))?;
7391 if lookups.len() != round.len() {
7392 return Err(self.latch(NodeError::Invariant(
7393 "SQLite bulk receipt precheck returned a misaligned result vector".into(),
7394 )));
7395 }
7396 for (index, lookup) in round.into_iter().zip(lookups) {
7397 checked.push((
7398 index,
7399 match lookup {
7400 Ok(Some((outcome, Some(sql_result)))) => Ok(Some((outcome, sql_result))),
7401 Ok(Some((_, None))) => Err(self.latch(NodeError::Invariant(
7402 "stored SQL receipt omitted its command result".into(),
7403 ))),
7404 Ok(None) => Ok(None),
7405 Err(error) => Err(self.map_sql_batch_member_error(error)),
7406 },
7407 ));
7408 }
7409 }
7410 Ok(checked)
7411 }
7412
7413 #[cfg(feature = "kv")]
7414 fn execute_kv_group_commit(&self, members: Vec<RuntimeBatchMember>) -> KvGroupCommitResult {
7415 let (job, leader) = self
7416 .kv_group_commit
7417 .enqueue(members, &self.operation_cancelled)?;
7418 if leader {
7419 self.run_kv_group_commit_leader();
7420 }
7421 job.wait(&self.operation_cancelled)
7422 }
7423
7424 #[cfg(feature = "kv")]
7425 fn run_kv_group_commit_leader(&self) {
7426 let _commit = match self.lock_commit() {
7427 Ok(commit) => commit,
7428 Err(error) => {
7429 self.kv_group_commit.fail_pending(error);
7430 return;
7431 }
7432 };
7433
7434 loop {
7435 if !self
7436 .kv_group_commit
7437 .collect_until_full_or_timeout(self.config.writer_batch_window())
7438 {
7439 break;
7440 }
7441 let Some(jobs) = self.kv_group_commit.drain_next_group() else {
7442 break;
7443 };
7444 if let Err(error) = self
7445 .ensure_ready()
7446 .and_then(|_| self.ensure_writes_active())
7447 {
7448 for job in &jobs {
7449 job.publish(Err(error.clone()));
7450 }
7451 self.kv_group_commit.fail_pending(error);
7452 return;
7453 }
7454 let execution = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7455 #[cfg(test)]
7456 if let Some(hook) = &self.kv_group_commit_before_execute_hook {
7457 hook();
7458 }
7459 self.execute_kv_group_locked(&jobs)
7460 }));
7461 let grouped_results = match execution {
7462 Ok(Ok(grouped_results)) => grouped_results,
7463 Ok(Err(error)) => {
7464 let error = if matches!(error, NodeError::Unavailable(_)) {
7465 error
7466 } else {
7467 self.latch(error)
7468 };
7469 for job in &jobs {
7470 job.publish(Err(error.clone()));
7471 }
7472 self.kv_group_commit.fail_pending(error);
7473 return;
7474 }
7475 Err(_) => {
7476 let error =
7477 self.latch(NodeError::Fatal("KV group commit leader panicked".into()));
7478 for job in &jobs {
7479 job.publish(Err(error.clone()));
7480 }
7481 self.kv_group_commit.fail_pending(error);
7482 return;
7483 }
7484 };
7485 for (job, results) in jobs.iter().zip(grouped_results) {
7486 job.publish(Ok(results));
7487 }
7488 }
7489 }
7490
7491 #[cfg(feature = "kv")]
7492 fn execute_kv_group_locked(
7493 &self,
7494 jobs: &[Arc<KvGroupCommitJob>],
7495 ) -> Result<Vec<Vec<Result<ClientWriteResponse, NodeError>>>, NodeError> {
7496 if self.operation_cancelled.load(Ordering::Acquire) {
7497 return Err(NodeError::Unavailable(
7498 "KV group commit cancelled during shutdown".into(),
7499 ));
7500 }
7501 let total_members = jobs.iter().map(|job| job.member_count).sum();
7502 if total_members == 0 || total_members > MAX_KV_GROUP_COMMIT_MEMBERS {
7503 return Err(NodeError::Invariant(format!(
7504 "KV group commit drained {total_members} members outside 1..={MAX_KV_GROUP_COMMIT_MEMBERS}"
7505 )));
7506 }
7507 let mut members = Vec::with_capacity(total_members);
7508 for job in jobs {
7509 let job_members = job.take_members()?;
7510 if job_members.len() != job.member_count {
7511 return Err(NodeError::Invariant(
7512 "KV group commit job member count changed while queued".into(),
7513 ));
7514 }
7515 members.extend(job_members);
7516 }
7517 let results = self.execute_kv_client_batch_locked(&members);
7518 #[cfg(test)]
7519 if let Some(hook) = &self.kv_group_commit_after_execute_hook {
7520 hook(self);
7521 }
7522 if results.len() != total_members {
7523 let error = self.latch(NodeError::Invariant(format!(
7524 "KV group commit returned {} results for {total_members} members",
7525 results.len()
7526 )));
7527 return Ok(jobs
7528 .iter()
7529 .map(|job| vec![Err(error.clone()); job.member_count])
7530 .collect());
7531 }
7532 let mut results = results.into_iter();
7533 let grouped = jobs
7534 .iter()
7535 .map(|job| results.by_ref().take(job.member_count).collect::<Vec<_>>())
7536 .collect::<Vec<_>>();
7537 debug_assert!(results.next().is_none());
7538 debug_assert_eq!(grouped.iter().map(Vec::len).sum::<usize>(), total_members);
7539 Ok(grouped)
7540 }
7541
7542 #[cfg(not(feature = "sql"))]
7543 fn execute_client_batch(
7544 &self,
7545 members: Vec<RuntimeBatchMember>,
7546 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7547 self.execute_profile_client_batch(members)
7548 }
7549
7550 fn execute_profile_client_batch(
7551 &self,
7552 members: Vec<RuntimeBatchMember>,
7553 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7554 #[cfg(feature = "graph")]
7555 if self.config.execution_profile == ExecutionProfile::Graph {
7556 return self.execute_graph_client_batch(members);
7557 }
7558 #[cfg(feature = "kv")]
7559 if self.config.execution_profile == ExecutionProfile::Kv {
7560 return self.execute_kv_client_batch(members);
7561 }
7562 let _commit = match self.lock_commit() {
7563 Ok(commit) => commit,
7564 Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7565 };
7566 if let Err(error) = self
7567 .ensure_ready()
7568 .and_then(|_| self.ensure_writes_active())
7569 {
7570 return members.into_iter().map(|_| Err(error.clone())).collect();
7571 }
7572 members
7573 .iter()
7574 .map(|member| self.execute_profile_member_locked(member))
7575 .collect()
7576 }
7577
7578 #[cfg(feature = "graph")]
7579 #[cfg_attr(
7580 all(not(feature = "sql"), not(feature = "kv")),
7581 allow(irrefutable_let_patterns, unreachable_patterns)
7582 )]
7583 fn execute_graph_client_batch(
7584 &self,
7585 members: Vec<RuntimeBatchMember>,
7586 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7587 let _commit = match self.lock_commit() {
7588 Ok(commit) => commit,
7589 Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7590 };
7591 if let Err(error) = self
7592 .ensure_ready()
7593 .and_then(|_| self.ensure_writes_active())
7594 {
7595 return members.into_iter().map(|_| Err(error.clone())).collect();
7596 }
7597
7598 let mut results = vec![None; members.len()];
7599 let mut pending = Vec::new();
7600 let mut canonical_by_request = HashMap::new();
7601 let mut aliases = vec![None; members.len()];
7602 for (index, member) in members.iter().enumerate() {
7603 let QueuedOperation::Graph(command) = &member.operation else {
7604 results[index] = Some(Err(NodeError::ExecutionProfileMismatch {
7605 expected: ExecutionProfile::Graph,
7606 actual: ExecutionProfile::Sqlite,
7607 }));
7608 continue;
7609 };
7610 match self.check_graph_request(command.request_id(), &member.payload) {
7611 Ok(Some(record)) => {
7612 results[index] = Some(Ok(ClientWriteResponse::Graph(
7613 GraphMutationOutcome::from_record(record),
7614 )));
7615 }
7616 Ok(None) => match classify_pending_request(
7617 &mut canonical_by_request,
7618 &members,
7619 index,
7620 command.request_id(),
7621 ) {
7622 Ok(None) => pending.push(index),
7623 Ok(Some(canonical)) => aliases[index] = Some(canonical),
7624 Err(error) => results[index] = Some(Err(error)),
7625 },
7626 Err(error) => results[index] = Some(Err(error)),
7627 }
7628 }
7629
7630 while !pending.is_empty() {
7631 if pending.len() == 1 {
7632 let index = pending[0];
7633 results[index] = Some(self.execute_profile_member_locked(&members[index]));
7634 break;
7635 }
7636 let commands = pending
7637 .iter()
7638 .map(|index| match &members[*index].operation {
7639 QueuedOperation::Graph(command) => command.clone(),
7640 _ => unreachable!("graph pending members were validated above"),
7641 })
7642 .collect::<Vec<_>>();
7643 let full_payload = match encode_replicated_graph_batch(&commands) {
7644 Ok(payload) => payload,
7645 Err(error) => {
7646 let error = NodeError::InvalidRequest(error.to_string());
7647 for index in pending.drain(..) {
7648 results[index] = Some(Err(error.clone()));
7649 }
7650 break;
7651 }
7652 };
7653 let (proposal_count, proposal_payload) = if full_payload.len() <= MAX_COMMAND_BYTES {
7654 (commands.len(), full_payload)
7655 } else {
7656 let mut prefix = None;
7657 for count in (2..commands.len()).rev() {
7658 let payload = encode_replicated_graph_batch(&commands[..count])
7659 .expect("the validated graph batch prefix remains valid");
7660 if payload.len() <= MAX_COMMAND_BYTES {
7661 prefix = Some((count, payload));
7662 break;
7663 }
7664 }
7665 let Some(prefix) = prefix else {
7666 let index = pending.remove(0);
7667 results[index] = Some(self.execute_profile_member_locked(&members[index]));
7668 continue;
7669 };
7670 prefix
7671 };
7672 let proposed_indices = pending[..proposal_count].to_vec();
7673 let (last_index, last_hash) = match self.ensure_materialized_tip() {
7674 Ok(tip) => tip,
7675 Err(error) => {
7676 for index in pending.drain(..) {
7677 results[index] = Some(Err(error.clone()));
7678 }
7679 break;
7680 }
7681 };
7682 let slot = match last_index.checked_add(1) {
7683 Some(slot) => slot,
7684 None => {
7685 let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7686 for index in pending.drain(..) {
7687 results[index] = Some(Err(error.clone()));
7688 }
7689 break;
7690 }
7691 };
7692 let entry = match self.consensus.propose_at_cancellable(
7693 slot,
7694 last_hash,
7695 Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7696 &self.operation_cancelled,
7697 ) {
7698 Ok(entry) => entry,
7699 Err(error) => {
7700 let error = self.map_consensus_error(error);
7701 for index in pending.drain(..) {
7702 results[index] = Some(Err(error.clone()));
7703 }
7704 break;
7705 }
7706 };
7707 if let Err(error) = self.persist_entry(&entry, slot, last_hash) {
7708 for index in pending.drain(..) {
7709 results[index] = Some(Err(error.clone()));
7710 }
7711 break;
7712 }
7713
7714 let mut remaining = Vec::new();
7715 for index in pending.drain(..) {
7716 let member = &members[index];
7717 let QueuedOperation::Graph(command) = &member.operation else {
7718 unreachable!("graph pending members were validated above");
7719 };
7720 match self.check_graph_request(command.request_id(), &member.payload) {
7721 Ok(Some(record)) => {
7722 results[index] = Some(Ok(ClientWriteResponse::Graph(
7723 GraphMutationOutcome::from_record(record),
7724 )));
7725 }
7726 Ok(None) => remaining.push(index),
7727 Err(error) => results[index] = Some(Err(error)),
7728 }
7729 }
7730 if entry.entry_type == EntryType::Command
7731 && entry.payload == proposal_payload
7732 && remaining
7733 .iter()
7734 .any(|index| proposed_indices.contains(index))
7735 {
7736 let error = self.latch(NodeError::Invariant(
7737 "committed graph batch did not record every request".into(),
7738 ));
7739 for index in remaining.drain(..) {
7740 results[index] = Some(Err(error.clone()));
7741 }
7742 }
7743 pending = remaining;
7744 }
7745
7746 for (index, canonical) in aliases.into_iter().enumerate() {
7747 if let Some(canonical) = canonical {
7748 results[index] = results[canonical].clone();
7749 }
7750 }
7751
7752 results
7753 .into_iter()
7754 .map(|result| {
7755 result.unwrap_or_else(|| {
7756 Err(self.latch(NodeError::Invariant(
7757 "graph writer batch omitted a request result".into(),
7758 )))
7759 })
7760 })
7761 .collect()
7762 }
7763
7764 #[cfg(feature = "kv")]
7765 #[cfg_attr(
7766 all(not(feature = "sql"), not(feature = "graph")),
7767 allow(irrefutable_let_patterns, unreachable_patterns)
7768 )]
7769 fn execute_kv_client_batch(
7770 &self,
7771 members: Vec<RuntimeBatchMember>,
7772 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7773 let _commit = match self.lock_commit() {
7774 Ok(commit) => commit,
7775 Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7776 };
7777 if let Err(error) = self
7778 .ensure_ready()
7779 .and_then(|_| self.ensure_writes_active())
7780 {
7781 return members.into_iter().map(|_| Err(error.clone())).collect();
7782 }
7783 self.execute_kv_client_batch_locked(&members)
7784 }
7785
7786 #[cfg(feature = "kv")]
7787 #[cfg_attr(
7788 all(not(feature = "sql"), not(feature = "graph")),
7789 allow(irrefutable_let_patterns, unreachable_patterns)
7790 )]
7791 fn execute_kv_client_batch_locked(
7792 &self,
7793 members: &[RuntimeBatchMember],
7794 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7795 let mut results = vec![None; members.len()];
7796 let mut pending = Vec::new();
7797 let mut canonical_by_request = HashMap::new();
7798 let mut aliases = vec![None; members.len()];
7799 let mut lookup_indices = Vec::with_capacity(members.len());
7800 for (index, member) in members.iter().enumerate() {
7801 let QueuedOperation::Kv(command) = &member.operation else {
7802 results[index] = Some(Err(NodeError::ExecutionProfileMismatch {
7803 expected: ExecutionProfile::Kv,
7804 actual: ExecutionProfile::Sqlite,
7805 }));
7806 continue;
7807 };
7808 match classify_pending_request(
7809 &mut canonical_by_request,
7810 members,
7811 index,
7812 command.request_id(),
7813 ) {
7814 Ok(None) => lookup_indices.push(index),
7815 Ok(Some(canonical)) => aliases[index] = Some(canonical),
7816 Err(error) => results[index] = Some(Err(error)),
7817 }
7818 }
7819 let preflight = match self.check_kv_members_bulk(members, &lookup_indices) {
7820 Ok(preflight) => preflight,
7821 Err(error) => return members.iter().map(|_| Err(error.clone())).collect(),
7822 };
7823 for (index, lookup) in preflight {
7824 match lookup {
7825 Ok(Some(record)) => {
7826 results[index] = Some(Ok(ClientWriteResponse::Kv(
7827 KvMutationOutcome::from_record(record),
7828 )));
7829 }
7830 Ok(None) => pending.push(index),
7831 Err(error) => results[index] = Some(Err(error)),
7832 }
7833 }
7834
7835 while !pending.is_empty() {
7836 if pending.len() == 1 {
7837 let index = pending[0];
7838 results[index] = Some(self.execute_profile_member_locked(&members[index]));
7839 break;
7840 }
7841 let commands = pending
7842 .iter()
7843 .map(|index| match &members[*index].operation {
7844 QueuedOperation::Kv(command) => command.clone(),
7845 _ => unreachable!("KV pending members were validated above"),
7846 })
7847 .collect::<Vec<_>>();
7848 let full_payload = match encode_replicated_kv_batch(&commands) {
7849 Ok(payload) => payload,
7850 Err(error) => {
7851 let error = NodeError::InvalidRequest(error.to_string());
7852 for index in pending.drain(..) {
7853 results[index] = Some(Err(error.clone()));
7854 }
7855 break;
7856 }
7857 };
7858 let (proposal_count, proposal_payload) = if full_payload.len() <= MAX_COMMAND_BYTES {
7859 (commands.len(), full_payload)
7860 } else {
7861 let Some(prefix) = largest_fitting_kv_batch_prefix(&commands) else {
7862 let index = pending.remove(0);
7863 results[index] = Some(self.execute_profile_member_locked(&members[index]));
7864 continue;
7865 };
7866 prefix
7867 };
7868 let proposed_indices = pending[..proposal_count].to_vec();
7869 let (last_index, last_hash) = match self.ensure_materialized_tip() {
7870 Ok(tip) => tip,
7871 Err(error) => {
7872 for index in pending.drain(..) {
7873 results[index] = Some(Err(error.clone()));
7874 }
7875 break;
7876 }
7877 };
7878 let slot = match last_index.checked_add(1) {
7879 Some(slot) => slot,
7880 None => {
7881 let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7882 for index in pending.drain(..) {
7883 results[index] = Some(Err(error.clone()));
7884 }
7885 break;
7886 }
7887 };
7888 let entry = match self.consensus.propose_at_cancellable(
7889 slot,
7890 last_hash,
7891 Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7892 &self.operation_cancelled,
7893 ) {
7894 Ok(entry) => entry,
7895 Err(error) => {
7896 let error = self.map_consensus_error(error);
7897 for index in pending.drain(..) {
7898 results[index] = Some(Err(error.clone()));
7899 }
7900 break;
7901 }
7902 };
7903 if let Err(error) = self.persist_entry(&entry, slot, last_hash) {
7904 for index in pending.drain(..) {
7905 results[index] = Some(Err(error.clone()));
7906 }
7907 break;
7908 }
7909
7910 let current_pending = std::mem::take(&mut pending);
7911 let post_commit = match self.check_kv_members_bulk(members, ¤t_pending) {
7912 Ok(post_commit) => post_commit,
7913 Err(error) => {
7914 for index in current_pending {
7915 results[index] = Some(Err(error.clone()));
7916 }
7917 break;
7918 }
7919 };
7920 let mut remaining = Vec::new();
7921 for (index, lookup) in post_commit {
7922 match lookup {
7923 Ok(Some(record)) => {
7924 results[index] = Some(Ok(ClientWriteResponse::Kv(
7925 KvMutationOutcome::from_record(record),
7926 )));
7927 }
7928 Ok(None) => remaining.push(index),
7929 Err(error) => results[index] = Some(Err(error)),
7930 }
7931 }
7932 if entry.entry_type == EntryType::Command
7933 && entry.payload == proposal_payload
7934 && remaining
7935 .iter()
7936 .any(|index| proposed_indices.contains(index))
7937 {
7938 let error = self.latch(NodeError::Invariant(
7939 "committed KV batch did not record every request".into(),
7940 ));
7941 for index in remaining.drain(..) {
7942 results[index] = Some(Err(error.clone()));
7943 }
7944 }
7945 pending = remaining;
7946 }
7947
7948 for (index, canonical) in aliases.into_iter().enumerate() {
7949 if let Some(canonical) = canonical {
7950 results[index] = results[canonical].clone();
7951 }
7952 }
7953
7954 results
7955 .into_iter()
7956 .map(|result| {
7957 result.unwrap_or_else(|| {
7958 Err(self.latch(NodeError::Invariant(
7959 "KV writer batch omitted a request result".into(),
7960 )))
7961 })
7962 })
7963 .collect()
7964 }
7965
7966 #[cfg(feature = "kv")]
7967 #[allow(clippy::infallible_destructuring_match)]
7968 fn check_kv_members_bulk(
7969 &self,
7970 members: &[RuntimeBatchMember],
7971 indices: &[usize],
7972 ) -> Result<Vec<KvMemberCheck>, NodeError> {
7973 if indices.is_empty() {
7974 return Ok(Vec::new());
7975 }
7976 let materializer = self.lock_materializer()?;
7977 let kv = match &*materializer {
7978 Materializer::Kv(kv) => kv,
7979 #[cfg(feature = "sql")]
7980 Materializer::Sql(_) => {
7981 return Err(NodeError::ExecutionProfileMismatch {
7982 expected: ExecutionProfile::Kv,
7983 actual: ExecutionProfile::Sqlite,
7984 });
7985 }
7986 #[cfg(feature = "graph")]
7987 Materializer::Graph(_) => {
7988 return Err(NodeError::ExecutionProfileMismatch {
7989 expected: ExecutionProfile::Kv,
7990 actual: ExecutionProfile::Graph,
7991 });
7992 }
7993 };
7994 let requests = indices
7995 .iter()
7996 .map(|index| {
7997 let command = match &members[*index].operation {
7998 QueuedOperation::Kv(command) => command,
7999 #[cfg(feature = "sql")]
8000 QueuedOperation::KeyValue { .. } | QueuedOperation::Sql(_) => {
8001 unreachable!("KV receipt lookup contains only KV members")
8002 }
8003 #[cfg(feature = "graph")]
8004 QueuedOperation::Graph(_) => {
8005 unreachable!("KV receipt lookup contains only KV members")
8006 }
8007 };
8008 (command.request_id(), members[*index].payload.as_slice())
8009 })
8010 .collect::<Vec<_>>();
8011 let lookups = kv
8012 .check_requests(&requests)
8013 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
8014 if lookups.len() != indices.len() {
8015 return Err(self.latch(NodeError::Invariant(
8016 "KV bulk receipt lookup returned a misaligned result vector".into(),
8017 )));
8018 }
8019 Ok(indices
8020 .iter()
8021 .copied()
8022 .zip(
8023 lookups.into_iter().map(|lookup| {
8024 lookup.map_err(|error| NodeError::InvalidRequest(error.to_string()))
8025 }),
8026 )
8027 .collect())
8028 }
8029
8030 fn execute_profile_member_locked(
8031 &self,
8032 member: &RuntimeBatchMember,
8033 ) -> Result<ClientWriteResponse, NodeError> {
8034 match &member.operation {
8035 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
8036 QueuedOperation::Unavailable => unreachable!("no execution profiles are compiled in"),
8037 #[cfg(feature = "graph")]
8038 QueuedOperation::Graph(command) => self
8039 .mutate_graph_payload_locked(command, member.payload.clone())
8040 .map(ClientWriteResponse::Graph),
8041 #[cfg(feature = "kv")]
8042 QueuedOperation::Kv(command) => self
8043 .mutate_kv_payload_locked(command, member.payload.clone())
8044 .map(ClientWriteResponse::Kv),
8045 #[cfg(feature = "sql")]
8046 QueuedOperation::KeyValue { .. } | QueuedOperation::Sql(_) => {
8047 Err(NodeError::ExecutionProfileMismatch {
8048 expected: self.config.execution_profile,
8049 actual: ExecutionProfile::Sqlite,
8050 })
8051 }
8052 }
8053 }
8054
8055 #[cfg(feature = "sql")]
8056 fn execute_single_member_locked(
8057 &self,
8058 member: &RuntimeBatchMember,
8059 ) -> Result<ClientWriteResponse, NodeError> {
8060 if let QueuedOperation::Sql(command) = &member.operation {
8061 self.execute_sql_payload_locked(command, member.payload.clone())
8062 .map(|outcome| {
8063 ClientWriteResponse::Sql(sql_execute_response(
8064 outcome.response,
8065 outcome.sql_result,
8066 ))
8067 })
8068 } else if let QueuedOperation::KeyValue { key, value } = &member.operation {
8069 self.execute_put_payload_locked(&member.request_id, key, value, member.payload.clone())
8070 .map(|outcome| ClientWriteResponse::KeyValue(outcome.response))
8071 } else {
8072 Err(NodeError::ExecutionProfileMismatch {
8073 expected: ExecutionProfile::Sqlite,
8074 actual: self.config.execution_profile,
8075 })
8076 }
8077 }
8078
8079 #[cfg(feature = "sql")]
8080 fn execute_sql_payload_locked(
8081 &self,
8082 command: &SqlCommand,
8083 request_payload: Vec<u8>,
8084 ) -> Result<ExecutedPayload, NodeError> {
8085 self.ensure_ready()?;
8086 self.ensure_writes_active()?;
8087 if let Some(outcome) = self.check_request(&command.request_id, &request_payload)? {
8088 return Ok(ExecutedPayload {
8089 response: write_response(outcome),
8090 sql_result: self.replay_sql_result(
8091 &command.request_id,
8092 &request_payload,
8093 outcome,
8094 )?,
8095 });
8096 }
8097
8098 loop {
8099 let (last_index, last_hash) = self.ensure_materialized_tip()?;
8100 let proposal_payload =
8101 self.prepare_sql_proposal(command, &request_payload, last_index, last_hash)?;
8102 let slot = last_index.checked_add(1).ok_or_else(|| {
8103 self.latch(NodeError::Invariant("qlog index is exhausted".into()))
8104 })?;
8105 let entry = self
8106 .consensus
8107 .propose_at_cancellable(
8108 slot,
8109 last_hash,
8110 Command::new(CommandKind::Deterministic, proposal_payload.clone()),
8111 &self.operation_cancelled,
8112 )
8113 .map_err(|error| self.map_consensus_error(error))?;
8114 let sql_result = self.persist_entry(&entry, slot, last_hash)?;
8115 if let Some(outcome) = self.check_request(&command.request_id, &request_payload)? {
8116 return Ok(ExecutedPayload {
8117 response: write_response(outcome),
8118 sql_result: sql_result.or(self.replay_sql_result(
8119 &command.request_id,
8120 &request_payload,
8121 outcome,
8122 )?),
8123 });
8124 }
8125 if entry.entry_type == EntryType::Command && entry.payload == proposal_payload {
8126 return Err(self.latch(NodeError::Invariant(
8127 "committed SQL request was not recorded by SQLite".into(),
8128 )));
8129 }
8130 }
8131 }
8132
8133 #[cfg(feature = "sql")]
8134 fn prepare_sql_proposal(
8135 &self,
8136 command: &SqlCommand,
8137 request_payload: &[u8],
8138 base_index: LogIndex,
8139 base_hash: LogHash,
8140 ) -> Result<Vec<u8>, NodeError> {
8141 let sqlite = self.lock_sqlite()?;
8142 let preparation = sqlite.prepare_sql_batch_effect(
8143 &[SqlBatchMember {
8144 command,
8145 request_payload,
8146 }],
8147 base_index,
8148 base_hash,
8149 );
8150 let preparation = match preparation {
8151 Ok(preparation) => preparation,
8152 Err(rhiza_sql::Error::ResourceExhausted(message)) => {
8153 return Err(NodeError::ResourceExhausted(message));
8154 }
8155 Err(error) => return Err(self.map_sqlite_error(error)),
8156 };
8157 let result = preparation
8158 .results
8159 .into_iter()
8160 .next()
8161 .expect("one-member SQL preparation returns one result");
8162 if let Err(error) = result {
8163 if let rhiza_sql::Error::ResourceExhausted(message) = error {
8164 return Err(NodeError::ResourceExhausted(message));
8165 }
8166 if let rhiza_sql::Error::RequestConflict(conflict) = error {
8167 return Err(NodeError::RequestConflict(conflict));
8168 }
8169 let message = error.to_string();
8170 let statement_index = first_invalid_sql_statement(command, |prefix| {
8171 let Ok(prefix_payload) = encode_sql_command(prefix) else {
8172 return true;
8173 };
8174 let prefix_member = [SqlBatchMember {
8175 command: prefix,
8176 request_payload: &prefix_payload,
8177 }];
8178 match sqlite.prepare_sql_batch_effect(&prefix_member, base_index, base_hash) {
8179 Ok(preparation) => preparation
8180 .results
8181 .into_iter()
8182 .next()
8183 .is_none_or(|result| result.is_err()),
8184 Err(_) => true,
8185 }
8186 });
8187 return match statement_index {
8188 Some(statement_index) => Err(NodeError::InvalidSqlStatement {
8189 statement_index,
8190 message,
8191 }),
8192 None => Err(NodeError::InvalidRequest(message)),
8193 };
8194 }
8195 let payload = preparation.effect.ok_or_else(|| {
8196 self.latch(NodeError::Invariant(
8197 "successful SQL preparation omitted its QWAL v3 effect".into(),
8198 ))
8199 })?;
8200 if !payload.starts_with(QWAL_V3_MAGIC) {
8201 return Err(self.latch(NodeError::Invariant(
8202 "SQLite materializer prepared a non-QWAL v3 SQL proposal".into(),
8203 )));
8204 }
8205 if payload.len() > MAX_COMMAND_BYTES {
8206 return Err(NodeError::ResourceExhausted(format!(
8207 "SQL effect exceeds {MAX_COMMAND_BYTES} bytes"
8208 )));
8209 }
8210 Ok(payload)
8211 }
8212
8213 #[cfg(feature = "sql")]
8214 fn execute_put_payload_locked(
8215 &self,
8216 request_id: &str,
8217 key: &str,
8218 value: &str,
8219 payload: Vec<u8>,
8220 ) -> Result<ExecutedPayload, NodeError> {
8221 self.ensure_ready()?;
8222 self.ensure_writes_active()?;
8223
8224 if let Some(outcome) = self.check_request(request_id, &payload)? {
8225 return Ok(ExecutedPayload {
8226 response: write_response(outcome),
8227 sql_result: None,
8228 });
8229 }
8230
8231 loop {
8232 let (last_index, last_hash) = self.ensure_materialized_tip()?;
8233 let proposal_payload = self
8234 .lock_sqlite()?
8235 .prepare_put_effect(request_id, key, value, &payload, last_index, last_hash)
8236 .map_err(|error| self.map_sqlite_error(error))?;
8237 if !proposal_payload.starts_with(QWAL_V3_MAGIC) {
8238 return Err(self.latch(NodeError::Invariant(
8239 "SQLite materializer prepared a non-QWAL v3 key/value write proposal".into(),
8240 )));
8241 }
8242 if proposal_payload.len() > MAX_COMMAND_BYTES {
8243 return Err(NodeError::ResourceExhausted(format!(
8244 "SQLite QWAL effect exceeds {MAX_COMMAND_BYTES} bytes"
8245 )));
8246 }
8247 let slot = last_index.checked_add(1).ok_or_else(|| {
8248 self.latch(NodeError::Invariant("qlog index is exhausted".into()))
8249 })?;
8250 let entry = self
8251 .consensus
8252 .propose_at_cancellable(
8253 slot,
8254 last_hash,
8255 Command::new(CommandKind::Deterministic, proposal_payload.clone()),
8256 &self.operation_cancelled,
8257 )
8258 .map_err(|error| self.map_consensus_error(error))?;
8259 self.persist_entry(&entry, slot, last_hash)?;
8260
8261 if let Some(outcome) = self.check_request(request_id, &payload)? {
8262 return Ok(ExecutedPayload {
8263 response: write_response(outcome),
8264 sql_result: None,
8265 });
8266 }
8267 if entry.entry_type == EntryType::Command && entry.payload == proposal_payload {
8268 return Err(self.latch(NodeError::Invariant(
8269 "committed key/value write request was not recorded by SQLite QWAL".into(),
8270 )));
8271 }
8272 }
8273 }
8274
8275 #[cfg(feature = "sql")]
8276 fn replay_sql_result(
8277 &self,
8278 request_id: &str,
8279 payload: &[u8],
8280 outcome: RequestOutcome,
8281 ) -> Result<Option<SqlCommandResult>, NodeError> {
8282 let sqlite = self.lock_sqlite()?;
8283 let stored = sqlite
8284 .check_sql_request(request_id, payload)
8285 .map_err(|error| self.map_sqlite_error(error))?
8286 .ok_or_else(|| {
8287 self.latch(NodeError::Invariant(
8288 "committed SQL request result is missing".into(),
8289 ))
8290 })?;
8291 if stored.0 != outcome {
8292 return Err(self.latch(NodeError::Invariant(
8293 "stored SQL request outcome changed".into(),
8294 )));
8295 }
8296 Ok(stored.1)
8297 }
8298
8299 #[cfg(feature = "sql")]
8300 fn map_sql_batch_member_error(&self, error: rhiza_sql::Error) -> NodeError {
8301 match error {
8302 rhiza_sql::Error::RequestConflict(conflict) => NodeError::RequestConflict(conflict),
8303 rhiza_sql::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
8304 rhiza_sql::Error::InvalidCommand(message) | rhiza_sql::Error::Sqlite(message) => {
8305 NodeError::InvalidSqlStatement {
8306 statement_index: 0,
8307 message,
8308 }
8309 }
8310 other => self.map_sqlite_error(other),
8311 }
8312 }
8313
8314 #[cfg(feature = "sql")]
8315 pub fn read(&self, key: &str, consistency: ReadConsistency) -> Result<ReadResponse, NodeError> {
8316 validate_key(key)?;
8317 match consistency {
8318 ReadConsistency::Local => self.read_local(key, None),
8319 ReadConsistency::AppliedIndex(required) => self.read_local(key, Some(required)),
8320 ReadConsistency::ReadBarrier => {
8321 let anchor = self.establish_read_barrier()?;
8322 let _commit = self.lock_commit()?;
8323 self.ensure_ready()?;
8324 self.ensure_writes_active()?;
8325 self.validate_read_barrier_descendant_locked(anchor)?;
8326 self.read_local(key, Some(anchor.index()))
8327 }
8328 }
8329 }
8330
8331 #[cfg(feature = "sql")]
8332 pub fn query_sql(
8333 &self,
8334 statement: &SqlStatement,
8335 consistency: ReadConsistency,
8336 max_rows: u32,
8337 ) -> Result<SqlQueryResponse, NodeError> {
8338 if max_rows == 0 || max_rows > MAX_SQL_MAX_ROWS {
8339 return Err(NodeError::InvalidRequest(format!(
8340 "max_rows must be between 1 and {MAX_SQL_MAX_ROWS}"
8341 )));
8342 }
8343 match consistency {
8344 ReadConsistency::Local => self.query_sql_local(statement, None, max_rows),
8345 ReadConsistency::AppliedIndex(required) => {
8346 self.query_sql_local(statement, Some(required), max_rows)
8347 }
8348 ReadConsistency::ReadBarrier => {
8349 let anchor = self.establish_read_barrier()?;
8350 let _commit = self.lock_commit()?;
8351 self.ensure_ready()?;
8352 self.ensure_writes_active()?;
8353 self.validate_read_barrier_descendant_locked(anchor)?;
8354 self.query_sql_local(statement, Some(anchor.index()), max_rows)
8355 }
8356 }
8357 }
8358
8359 pub fn applied_index(&self) -> Result<LogIndex, NodeError> {
8360 self.ensure_ready()?;
8361 self.lock_materializer()?
8362 .applied_index()
8363 .map_err(|error| self.latch(NodeError::Storage(error)))
8364 }
8365
8366 pub fn applied_hash(&self) -> Result<LogHash, NodeError> {
8367 self.ensure_ready()?;
8368 self.lock_materializer()?
8369 .applied_hash()
8370 .map_err(|error| self.latch(NodeError::Storage(error)))
8371 }
8372
8373 pub fn cancel_operations(&self) {
8374 self.operation_cancelled.store(true, Ordering::Release);
8375 #[cfg(feature = "sql")]
8376 self.sql_group_commit.fail_pending(NodeError::Unavailable(
8377 "SQL group commit cancelled during shutdown".into(),
8378 ));
8379 #[cfg(feature = "kv")]
8380 self.kv_group_commit.fail_pending(NodeError::Unavailable(
8381 "KV group commit cancelled during shutdown".into(),
8382 ));
8383 self.read_barriers.cancel_waiters();
8384 self.operation_cancelled_notify.notify_waiters();
8385 }
8386
8387 pub fn materialize_next_decision(&self) -> Result<bool, NodeError> {
8388 let _commit = self.lock_commit()?;
8389 self.ensure_ready()?;
8390 let (last_index, last_hash) = self.ensure_materialized_tip()?;
8391 let slot = last_index
8392 .checked_add(1)
8393 .ok_or_else(|| self.latch(NodeError::Invariant("qlog index is exhausted".into())))?;
8394 match self
8395 .consensus
8396 .inspect_decision_at(slot, last_hash)
8397 .map_err(|error| self.map_consensus_error(error))?
8398 {
8399 DecisionInspection::Committed(entry) => {
8400 self.persist_entry(&entry, slot, last_hash)?;
8401 Ok(true)
8402 }
8403 DecisionInspection::Empty | DecisionInspection::Pending => Ok(false),
8404 DecisionInspection::Unavailable => Err(NodeError::Unavailable(
8405 "decision proof inspection did not reach quorum".into(),
8406 )),
8407 }
8408 }
8409
8410 pub async fn run_background_materializer<F>(
8411 self: Arc<Self>,
8412 poll_interval: Duration,
8413 shutdown: F,
8414 ) -> Result<(), NodeError>
8415 where
8416 F: std::future::Future<Output = ()> + Send,
8417 {
8418 let poll_interval = poll_interval.max(Duration::from_millis(10));
8419 tokio::pin!(shutdown);
8420 loop {
8421 tokio::select! {
8422 () = &mut shutdown => return Ok(()),
8423 () = tokio::time::sleep(poll_interval) => {
8424 loop {
8425 let runtime = Arc::clone(&self);
8426 let mut operation = tokio::task::spawn_blocking(move || runtime.materialize_next_decision());
8427 let (result, shutting_down) = tokio::select! {
8428 () = &mut shutdown => {
8429 self.cancel_operations();
8430 (operation.await, true)
8431 }
8432 result = &mut operation => (result, false),
8433 };
8434 if shutting_down {
8435 return match result {
8436 Ok(Ok(_) | Err(NodeError::Unavailable(_) | NodeError::Contention(_))) => Ok(()),
8437 Ok(Err(error)) => Err(error),
8438 Err(error) => Err(NodeError::Fatal(format!("materializer task failed: {error}"))),
8439 };
8440 }
8441 match result {
8442 Ok(Ok(true)) => continue,
8443 Ok(Ok(false) | Err(NodeError::Unavailable(_) | NodeError::Contention(_))) => break,
8444 Ok(Err(error)) => return Err(error),
8445 Err(error) => return Err(NodeError::Fatal(format!("materializer task failed: {error}"))),
8446 }
8447 }
8448 }
8449 }
8450 }
8451 }
8452
8453 pub const fn config(&self) -> &NodeConfig {
8454 &self.config
8455 }
8456
8457 pub const fn consensus(&self) -> &Arc<ThreeNodeConsensus> {
8458 &self.consensus
8459 }
8460
8461 pub const fn log_store(&self) -> &FileLogStore {
8462 &self.log_store
8463 }
8464
8465 pub fn configuration_state(&self) -> Result<ConfigurationState, NodeError> {
8466 self.log_store
8467 .configuration_state()
8468 .map_err(|error| NodeError::Storage(error.to_string()))
8469 }
8470
8471 pub fn status(&self) -> Result<NodeStatus, NodeError> {
8472 let configuration_state = self.configuration_state()?;
8473 let (configuration_status, active_config_id) = if configuration_state.is_active() {
8474 (
8475 RuntimeConfigurationStatus::Active,
8476 configuration_state.config_id(),
8477 )
8478 } else if configuration_state.config_id() == self.consensus.config_id() {
8479 (
8480 RuntimeConfigurationStatus::Stopped,
8481 configuration_state.config_id(),
8482 )
8483 } else {
8484 (
8485 RuntimeConfigurationStatus::AwaitingActivation,
8486 configuration_state
8487 .config_id()
8488 .checked_add(1)
8489 .ok_or_else(|| {
8490 NodeError::Invariant("successor configuration id is exhausted".into())
8491 })?,
8492 )
8493 };
8494 Ok(NodeStatus {
8495 ready: self.is_ready(),
8496 stop_anchor: configuration_state.stop().copied(),
8497 active_config_id,
8498 active_membership_digest: self.config.membership.digest(),
8499 configuration_status,
8500 configuration_state,
8501 })
8502 }
8503
8504 pub fn stop_current_configuration_for_successor(
8505 &self,
8506 successor: &Membership,
8507 ) -> Result<StopInformation, NodeError> {
8508 let _commit = self.lock_commit()?;
8509 self.stop_current_configuration_locked(successor)
8510 }
8511
8512 fn stop_current_configuration_locked(
8513 &self,
8514 successor: &Membership,
8515 ) -> Result<StopInformation, NodeError> {
8516 self.ensure_ready()?;
8517 self.ensure_writes_active()?;
8518 let state = self.configuration_state()?;
8519 let stop_command = ConfigChange::bound_stop(
8520 self.config.cluster_id.clone(),
8521 state.config_id(),
8522 state.digest(),
8523 state
8524 .config_id()
8525 .checked_add(1)
8526 .ok_or_else(|| NodeError::Invariant("successor config id is exhausted".into()))?,
8527 successor.members().to_vec(),
8528 )
8529 .map_err(|error| NodeError::Invariant(error.to_string()))?
8530 .to_stored_command();
8531 loop {
8532 let (last_index, last_hash) = self.ensure_materialized_tip()?;
8533 let slot = last_index
8534 .checked_add(1)
8535 .ok_or_else(|| NodeError::Invariant("qlog index is exhausted".into()))?;
8536 let entry = self
8537 .consensus
8538 .propose_stored_at(slot, last_hash, stop_command.clone())
8539 .map_err(|error| self.map_consensus_error(error))?;
8540 self.persist_entry(&entry, slot, last_hash)?;
8541 let decided = StoredCommand::new(entry.entry_type, entry.payload.clone());
8542 if decided != stop_command {
8543 let current = self.configuration_state()?;
8544 if current.is_active() {
8545 continue;
8546 }
8547 return Err(NodeError::ConfigurationTransition {
8548 state: Box::new(current),
8549 });
8550 }
8551 let proof = self
8552 .consensus
8553 .inspect_decision_proof_at(slot)
8554 .map_err(|error| self.map_consensus_error(error))?
8555 .ok_or_else(|| {
8556 NodeError::Unavailable("durable Stop proof is unavailable".into())
8557 })?;
8558 if proof
8559 .proposal()
8560 .value
8561 .as_ref()
8562 .map(|value| value.entry_hash)
8563 != Some(entry.hash)
8564 {
8565 return Err(self.latch(NodeError::Reconciliation(
8566 "Stop proof differs from committed stop entry".into(),
8567 )));
8568 }
8569 return Ok(StopInformation { entry, proof });
8570 }
8571 }
8572
8573 pub fn activate_successor(&self) -> Result<LogEntry, NodeError> {
8574 let _commit = self.lock_commit()?;
8575 self.activate_successor_locked(None)
8576 }
8577
8578 pub fn activate_successor_if(&self, expected_config_id: u64) -> Result<LogEntry, NodeError> {
8579 let _commit = self.lock_commit()?;
8580 self.activate_successor_locked(Some(expected_config_id))
8581 }
8582
8583 fn activate_successor_locked(
8584 &self,
8585 expected_config_id: Option<u64>,
8586 ) -> Result<LogEntry, NodeError> {
8587 self.ensure_ready()?;
8588 let state = self.configuration_state()?;
8589 let stop = state
8590 .stop()
8591 .copied()
8592 .ok_or_else(|| NodeError::ConfigurationTransition {
8593 state: Box::new(state.clone()),
8594 })?;
8595 if state.config_id() == self.consensus.config_id() {
8596 return Err(NodeError::ConfigurationTransition {
8597 state: Box::new(state),
8598 });
8599 }
8600 let successor_config_id = state.config_id().checked_add(1).ok_or_else(|| {
8601 NodeError::Invariant("successor configuration id is exhausted".into())
8602 })?;
8603 if expected_config_id.is_some_and(|expected| expected != successor_config_id) {
8604 return Err(NodeError::PreconditionFailed(
8605 "successor configuration does not match expected_config_id".into(),
8606 ));
8607 }
8608 let stop_entry = self.recover_stop_entry(stop)?;
8609 let entry = self
8610 .consensus
8611 .propose_activation_for_stop_entry(&stop_entry)
8612 .map_err(|error| self.map_consensus_error(error))?;
8613 self.persist_entry(&entry, stop.index() + 1, stop.hash())?;
8614 Ok(entry)
8615 }
8616
8617 pub(crate) fn recover_stop_entry(&self, stop: LogAnchor) -> Result<LogEntry, NodeError> {
8618 if let Some(entry) = self
8619 .log_store
8620 .read(stop.index())
8621 .map_err(|error| NodeError::Storage(error.to_string()))?
8622 .filter(|entry| entry.hash == stop.hash())
8623 {
8624 return Ok(entry);
8625 }
8626 if let Some(entry) = self
8627 .config
8628 .predecessor_stop_entry
8629 .as_ref()
8630 .filter(|entry| entry.index == stop.index() && entry.hash == stop.hash())
8631 {
8632 validate_entry_envelope(&self.config, entry, entry.index, entry.prev_hash)?;
8633 return Ok(entry.clone());
8634 }
8635 let proof = self
8636 .consensus
8637 .inspect_decision_proof_at(stop.index())
8638 .map_err(|error| self.map_consensus_error(error))?
8639 .ok_or_else(|| NodeError::Unavailable("durable Stop proof is unavailable".into()))?;
8640 let value = proof
8641 .proposal()
8642 .value
8643 .as_ref()
8644 .filter(|value| value.entry_hash == stop.hash())
8645 .ok_or_else(|| {
8646 self.latch(NodeError::Reconciliation(
8647 "Stop proof differs from compacted anchor".into(),
8648 ))
8649 })?;
8650 match self
8651 .consensus
8652 .inspect_decision_at(stop.index(), value.prev_hash)
8653 .map_err(|error| self.map_consensus_error(error))?
8654 {
8655 DecisionInspection::Committed(entry) if entry.hash == stop.hash() => Ok(entry),
8656 DecisionInspection::Unavailable => Err(NodeError::Unavailable(
8657 "durable Stop command is unavailable".into(),
8658 )),
8659 DecisionInspection::Committed(_)
8660 | DecisionInspection::Empty
8661 | DecisionInspection::Pending => Err(self.latch(NodeError::Reconciliation(
8662 "Stop decision differs from compacted anchor".into(),
8663 ))),
8664 }
8665 }
8666
8667 pub fn log_root(&self) -> Result<LogAnchor, NodeError> {
8668 let _commit = self.lock_commit()?;
8669 self.log_root_unlocked()
8670 }
8671
8672 fn log_root_unlocked(&self) -> Result<LogAnchor, NodeError> {
8673 let state = self
8674 .log_store
8675 .logical_state()
8676 .map_err(|error| NodeError::Storage(error.to_string()))?;
8677 Ok(state.tip.map_or_else(
8678 || {
8679 state
8680 .anchor
8681 .map_or(LogAnchor::new(0, LogHash::ZERO), |anchor| {
8682 *anchor.compacted()
8683 })
8684 },
8685 |entry| LogAnchor::new(entry.index(), entry.hash()),
8686 ))
8687 }
8688
8689 pub fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
8690 fetch_runtime_log(self, request)
8691 }
8692
8693 #[cfg(feature = "sql")]
8694 pub fn create_recovery_snapshot(&self) -> Result<RecoverySnapshot, NodeError> {
8695 let _commit = self.lock_commit()?;
8696 self.ensure_ready()?;
8697 self.ensure_materialized_tip()?;
8698 self.lock_sqlite()?
8699 .create_recovery_snapshot(self.config.recovery_generation)
8700 .map_err(|error| self.map_sqlite_error(error))
8701 }
8702
8703 pub async fn checkpoint_compact(
8704 &self,
8705 coordinator: &CheckpointCoordinator,
8706 ) -> Result<RecoveryAnchor, DurabilityError> {
8707 coordinator.checkpoint_compact(self).await
8708 }
8709
8710 #[cfg_attr(not(any(feature = "sql", feature = "kv")), allow(unused_variables))]
8711 pub(crate) fn compact_embedded_log_before(
8712 &self,
8713 anchor_index: LogIndex,
8714 ) -> Result<(), NodeError> {
8715 let materializer = self.lock_materializer()?;
8716 match &*materializer {
8717 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
8718 Materializer::Unavailable => unreachable!("no execution profiles are compiled in"),
8719 #[cfg(feature = "sql")]
8720 Materializer::Sql(sql) => sql
8721 .compact_embedded_log_before(anchor_index)
8722 .map_err(|error| self.map_sqlite_error(error)),
8723 #[cfg(feature = "kv")]
8724 Materializer::Kv(kv) => kv
8725 .compact_embedded_log_before(anchor_index)
8726 .map_err(|error| NodeError::Storage(error.to_string())),
8727 #[cfg(feature = "graph")]
8728 Materializer::Graph(_) => Ok(()),
8729 }
8730 }
8731
8732 #[cfg(feature = "sql")]
8733 pub fn verify_snapshot_publication(
8734 &self,
8735 snapshot: &RecoverySnapshot,
8736 publication: &SnapshotRecord,
8737 ) -> Result<VerifiedSnapshotPublication, NodeError> {
8738 let anchor = snapshot.anchor();
8739 let manifest = publication.manifest();
8740 let publication_digest = LogHash::from_hex(publication.sha256()).ok_or_else(|| {
8741 NodeError::Reconciliation("published snapshot digest is invalid".into())
8742 })?;
8743 if anchor.cluster_id() != self.config.cluster_id
8744 || anchor.epoch() != self.config.epoch
8745 || anchor.config_id() != self.config.config_id()
8746 || anchor.recovery_generation() != self.config.recovery_generation
8747 || manifest.cluster_id() != anchor.cluster_id()
8748 || manifest.epoch() != anchor.epoch()
8749 || manifest.config_id() != anchor.config_id()
8750 || manifest.configuration_state() != anchor.configuration_state()
8751 || manifest.index() != anchor.compacted().index()
8752 || manifest.applied_hash() != anchor.compacted().hash()
8753 || manifest.snapshot_id() != anchor.snapshot().snapshot_id()
8754 || manifest.executor_fingerprint() != anchor.snapshot().executor_fingerprint()
8755 || publication_digest != anchor.snapshot().digest()
8756 || publication.size_bytes() != anchor.snapshot().size_bytes()
8757 || LogHash::digest(&[snapshot.db_bytes()]) != anchor.snapshot().digest()
8758 || snapshot.db_bytes().len() as u64 != anchor.snapshot().size_bytes()
8759 {
8760 return Err(NodeError::Reconciliation(
8761 "published snapshot does not match the runtime recovery anchor".into(),
8762 ));
8763 }
8764 Ok(VerifiedSnapshotPublication {
8765 anchor: anchor.clone(),
8766 })
8767 }
8768
8769 #[cfg(feature = "sql")]
8770 pub fn compact_log(&self, publication: &VerifiedSnapshotPublication) -> Result<(), NodeError> {
8771 let _commit = self.lock_commit()?;
8772 self.ensure_ready()?;
8773 let applied_index = self.applied_index()?;
8774 let applied_hash = self.applied_hash()?;
8775 let anchor = &publication.anchor;
8776 if anchor.cluster_id() != self.config.cluster_id
8777 || anchor.epoch() != self.config.epoch
8778 || anchor.config_id() != self.config.config_id()
8779 || anchor.recovery_generation() != self.config.recovery_generation
8780 || anchor.compacted().index() != applied_index
8781 || anchor.compacted().hash() != applied_hash
8782 {
8783 return Err(NodeError::Reconciliation(
8784 "verified snapshot anchor does not match the current applied entry".into(),
8785 ));
8786 }
8787 self.log_store
8788 .compact_prefix(anchor)
8789 .map_err(|error| NodeError::Storage(error.to_string()))?;
8790 self.compact_embedded_log_before(anchor.compacted().index())
8791 }
8792
8793 pub fn is_ready(&self) -> bool {
8794 self.ready.load(Ordering::Acquire) && !self.fatal.load(Ordering::Acquire)
8795 }
8796
8797 pub fn is_fatal(&self) -> bool {
8798 self.fatal.load(Ordering::Acquire)
8799 }
8800
8801 pub fn fatal_reason(&self) -> Option<String> {
8802 self.fatal_reason
8803 .lock()
8804 .ok()
8805 .and_then(|reason| reason.clone())
8806 }
8807
8808 #[cfg(feature = "sql")]
8809 fn read_local(
8810 &self,
8811 key: &str,
8812 required_index: Option<LogIndex>,
8813 ) -> Result<ReadResponse, NodeError> {
8814 self.ensure_ready()?;
8815 self.ensure_writes_active()?;
8816 let sqlite = self.lock_sqlite()?;
8817 let (applied_index, hash) = sqlite
8818 .applied_tip_value()
8819 .map_err(|error| self.map_sqlite_error(error))?;
8820 if required_index.is_some_and(|required| applied_index < required) {
8821 return Err(NodeError::Unavailable(format!(
8822 "local applied index {applied_index} has not reached {}",
8823 required_index.expect("checked above")
8824 )));
8825 }
8826 let value = sqlite
8827 .get_value(key)
8828 .map_err(|error| self.map_sqlite_error(error))?;
8829 Ok(ReadResponse {
8830 value,
8831 applied_index,
8832 hash,
8833 })
8834 }
8835
8836 #[cfg(feature = "graph")]
8837 fn get_graph_document_local(
8838 &self,
8839 id: &str,
8840 required_index: Option<LogIndex>,
8841 ) -> Result<GraphReadResponse, NodeError> {
8842 self.ensure_ready()?;
8843 self.ensure_writes_active()?;
8844 let graph = self.graph_materializer()?;
8845 let (value, applied_index, hash) = graph
8846 .get_document_with_tip(id)
8847 .map_err(|error| self.map_graph_read_error(error))?;
8848 if required_index.is_some_and(|required| applied_index < required) {
8849 return Err(NodeError::Unavailable(format!(
8850 "local applied index {applied_index} has not reached {}",
8851 required_index.expect("checked above")
8852 )));
8853 }
8854 Ok(GraphReadResponse {
8855 value,
8856 applied_index,
8857 hash,
8858 })
8859 }
8860
8861 #[cfg(feature = "graph")]
8862 fn query_graph_local(
8863 &self,
8864 statement: &str,
8865 parameters: &BTreeMap<String, GraphParameterValue>,
8866 required_index: Option<LogIndex>,
8867 max_rows: u32,
8868 ) -> Result<GraphQueryResult, NodeError> {
8869 self.ensure_ready()?;
8870 self.ensure_writes_active()?;
8871 let graph = self.graph_materializer()?;
8872 let result = graph
8873 .query_read_only(
8874 statement,
8875 parameters,
8876 usize::try_from(max_rows).expect("u32 fits usize"),
8877 MAX_GRAPH_RESULT_BYTES,
8878 GRAPH_QUERY_TIMEOUT_MS,
8879 )
8880 .map_err(|error| self.map_graph_read_error(error))?;
8881 if required_index.is_some_and(|required| result.applied_index < required) {
8882 return Err(NodeError::Unavailable(format!(
8883 "local applied index {} has not reached {}",
8884 result.applied_index,
8885 required_index.expect("checked above")
8886 )));
8887 }
8888 Ok(result)
8889 }
8890
8891 #[cfg(feature = "kv")]
8892 fn get_kv_local(
8893 &self,
8894 key: &[u8],
8895 required_index: Option<LogIndex>,
8896 ) -> Result<KvReadResponse, NodeError> {
8897 self.ensure_ready()?;
8898 self.ensure_writes_active()?;
8899 let kv = self.kv_materializer()?;
8900 let result = kv
8901 .get_with_tip(key)
8902 .map_err(|error| self.map_kv_read_error(error))?;
8903 let (value, tip) = result.into_parts();
8904 let applied_index = tip.applied_index();
8905 if required_index.is_some_and(|required| applied_index < required) {
8906 return Err(NodeError::Unavailable(format!(
8907 "local applied index {applied_index} has not reached {}",
8908 required_index.expect("checked above")
8909 )));
8910 }
8911 Ok(KvReadResponse {
8912 value,
8913 applied_index,
8914 hash: tip.applied_hash(),
8915 })
8916 }
8917
8918 #[cfg(feature = "kv")]
8919 fn scan_kv_range_local(
8920 &self,
8921 start: &[u8],
8922 end: Option<&[u8]>,
8923 limit: usize,
8924 cursor: Option<&[u8]>,
8925 required_index: Option<LogIndex>,
8926 ) -> Result<KvScanResult, NodeError> {
8927 self.ensure_ready()?;
8928 self.ensure_writes_active()?;
8929 let kv = self.kv_materializer()?;
8930 let result = kv
8931 .scan_range(start, end, limit, cursor)
8932 .map_err(|error| self.map_kv_read_error(error))?;
8933 validate_kv_scan_required_index(&result, required_index)?;
8934 Ok(result)
8935 }
8936
8937 #[cfg(feature = "kv")]
8938 fn scan_kv_prefix_local(
8939 &self,
8940 prefix: &[u8],
8941 limit: usize,
8942 cursor: Option<&[u8]>,
8943 required_index: Option<LogIndex>,
8944 ) -> Result<KvScanResult, NodeError> {
8945 self.ensure_ready()?;
8946 self.ensure_writes_active()?;
8947 let kv = self.kv_materializer()?;
8948 let result = kv
8949 .scan_prefix(prefix, limit, cursor)
8950 .map_err(|error| self.map_kv_read_error(error))?;
8951 validate_kv_scan_required_index(&result, required_index)?;
8952 Ok(result)
8953 }
8954
8955 #[cfg(feature = "sql")]
8956 fn query_sql_local(
8957 &self,
8958 statement: &SqlStatement,
8959 required_index: Option<LogIndex>,
8960 max_rows: u32,
8961 ) -> Result<SqlQueryResponse, NodeError> {
8962 self.ensure_ready()?;
8963 self.ensure_writes_active()?;
8964 let sqlite = self.lock_sqlite()?;
8965 let (applied_index, hash) = sqlite
8966 .applied_tip_value()
8967 .map_err(|error| self.map_sqlite_error(error))?;
8968 if required_index.is_some_and(|required| applied_index < required) {
8969 return Err(NodeError::Unavailable(format!(
8970 "local applied index {applied_index} has not reached {}",
8971 required_index.expect("checked above")
8972 )));
8973 }
8974 let SqlQueryResult { columns, rows } = sqlite
8975 .query_sql(
8976 statement,
8977 usize::try_from(max_rows).expect("u32 fits usize"),
8978 MAX_SQL_RESULT_BYTES,
8979 )
8980 .map_err(|error| match error {
8981 rhiza_sql::Error::ResourceExhausted(message) => {
8982 NodeError::ResourceExhausted(message)
8983 }
8984 other => NodeError::InvalidSqlStatement {
8985 statement_index: 0,
8986 message: other.to_string(),
8987 },
8988 })?;
8989 Ok(SqlQueryResponse {
8990 columns,
8991 rows,
8992 applied_index,
8993 hash,
8994 })
8995 }
8996
8997 fn establish_read_barrier(&self) -> Result<LogAnchor, NodeError> {
8998 let participant = self.read_barriers.join().map_err(|error| match error {
8999 NodeError::Invariant(_) => self.latch(error),
9000 other => other,
9001 })?;
9002 let Some(mut publication) = participant.publication() else {
9003 return participant.wait(&self.operation_cancelled);
9004 };
9005
9006 let result = (|| {
9007 publication.wait_turn(&self.operation_cancelled)?;
9008 let _commit = self.lock_commit()?;
9012 publication.start(&self.operation_cancelled)?;
9013 self.ensure_ready()?;
9014 self.commit_read_barrier_locked()
9015 })();
9016 publication.publish(result.clone());
9017 result
9018 }
9019
9020 #[cfg(any(feature = "graph", feature = "kv"))]
9021 fn validate_read_barrier_before_snapshot(&self, anchor: LogAnchor) -> Result<(), NodeError> {
9022 {
9023 let _commit = self.lock_commit()?;
9024 self.ensure_ready()?;
9025 self.ensure_writes_active()?;
9026 self.validate_read_barrier_qlog_descendant_locked(anchor)?;
9027 }
9028 Ok(())
9029 }
9030
9031 #[cfg(any(feature = "graph", feature = "kv"))]
9032 fn validate_read_barrier_snapshot(
9033 &self,
9034 anchor: LogAnchor,
9035 observed: LogAnchor,
9036 ) -> Result<(), NodeError> {
9037 if observed.index() < anchor.index() {
9038 return Err(NodeError::Unavailable(format!(
9039 "read snapshot tip {} precedes read barrier {}",
9040 observed.index(),
9041 anchor.index()
9042 )));
9043 }
9044 if observed.index() == anchor.index() && observed.hash() != anchor.hash() {
9045 return Err(self.latch(NodeError::Invariant(
9046 "read snapshot tip hash differs from the read barrier anchor".into(),
9047 )));
9048 }
9049 Ok(())
9050 }
9051
9052 #[cfg(feature = "sql")]
9053 fn validate_read_barrier_descendant_locked(&self, anchor: LogAnchor) -> Result<(), NodeError> {
9054 let (applied_index, applied_hash) = self.ensure_materialized_tip()?;
9055 self.validate_read_barrier_descendant_from_tip(
9056 anchor,
9057 LogAnchor::new(applied_index, applied_hash),
9058 "materialized",
9059 )
9060 }
9061
9062 #[cfg(any(feature = "graph", feature = "kv"))]
9063 fn validate_read_barrier_qlog_descendant_locked(
9064 &self,
9065 anchor: LogAnchor,
9066 ) -> Result<(), NodeError> {
9067 let (qlog_index, qlog_hash) = self.durable_tip()?;
9068 self.validate_read_barrier_descendant_from_tip(
9069 anchor,
9070 LogAnchor::new(qlog_index, qlog_hash),
9071 "qlog",
9072 )
9073 }
9074
9075 fn validate_read_barrier_descendant_from_tip(
9076 &self,
9077 anchor: LogAnchor,
9078 tip: LogAnchor,
9079 tip_kind: &str,
9080 ) -> Result<(), NodeError> {
9081 if tip.index() < anchor.index() {
9082 return Err(self.latch(NodeError::Invariant(format!(
9083 "{tip_kind} tip {} precedes read barrier {}",
9084 tip.index(),
9085 anchor.index()
9086 ))));
9087 }
9088 if tip.index() == anchor.index() {
9089 if tip.hash() != anchor.hash() {
9090 return Err(self.latch(NodeError::Invariant(format!(
9091 "{tip_kind} tip hash differs from the read barrier anchor"
9092 ))));
9093 }
9094 return Ok(());
9095 }
9096 if anchor.index() == 0 {
9097 if anchor.hash() == LogHash::ZERO {
9098 return Ok(());
9099 }
9100 return Err(self.latch(NodeError::Invariant(
9101 "genesis read barrier anchor has a non-zero hash".into(),
9102 )));
9103 }
9104
9105 let logical = self
9106 .log_store
9107 .logical_state()
9108 .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9109 if let Some(compacted) = logical.anchor.as_ref().map(RecoveryAnchor::compacted) {
9110 if compacted.index() > anchor.index() {
9111 return Ok(());
9112 }
9113 if compacted.index() == anchor.index() {
9114 if compacted.hash() == anchor.hash() {
9115 return Ok(());
9116 }
9117 return Err(self.latch(NodeError::Invariant(
9118 "compacted qlog hash differs from the read barrier anchor".into(),
9119 )));
9120 }
9121 }
9122 let retained = self
9123 .log_store
9124 .read(anchor.index())
9125 .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?
9126 .ok_or_else(|| {
9127 self.latch(NodeError::Invariant(
9128 "read barrier anchor is neither retained nor compacted".into(),
9129 ))
9130 })?;
9131 if retained.hash != anchor.hash() {
9132 return Err(self.latch(NodeError::Invariant(
9133 "retained qlog hash differs from the read barrier anchor".into(),
9134 )));
9135 }
9136 Ok(())
9137 }
9138
9139 fn commit_read_barrier_locked(&self) -> Result<LogAnchor, NodeError> {
9140 self.ensure_writes_active()?;
9141 let context_read_fence = self.consensus.supports_context_read_fence();
9142 loop {
9143 self.ensure_ready()?;
9144 let (last_index, last_hash) = self.ensure_materialized_tip()?;
9145 let slot = last_index.checked_add(1).ok_or_else(|| {
9146 self.latch(NodeError::Invariant("qlog index is exhausted".into()))
9147 })?;
9148 let inspection = if context_read_fence {
9149 match self
9150 .consensus
9151 .inspect_context_read_fence_at(slot, last_hash)
9152 .map_err(|error| self.map_consensus_error(error))?
9153 {
9154 CertifiedDecisionInspection::Committed(certified) => {
9155 DecisionInspection::Committed(certified.entry)
9156 }
9157 CertifiedDecisionInspection::Empty => DecisionInspection::Empty,
9158 CertifiedDecisionInspection::Pending => DecisionInspection::Pending,
9159 CertifiedDecisionInspection::Unavailable => DecisionInspection::Unavailable,
9160 }
9161 } else {
9162 self.consensus
9163 .inspect_decision_at(slot, last_hash)
9164 .map_err(|error| self.map_consensus_error(error))?
9165 };
9166 match inspection {
9167 DecisionInspection::Committed(entry) => {
9168 self.persist_entry(&entry, slot, last_hash)?;
9169 }
9170 DecisionInspection::Empty if context_read_fence => {
9171 self.ensure_writes_active()?;
9175 return Ok(LogAnchor::new(last_index, last_hash));
9176 }
9177 DecisionInspection::Pending => {
9178 let entry = self
9179 .consensus
9180 .propose_at_cancellable(
9181 slot,
9182 last_hash,
9183 Command::new(CommandKind::ReadBarrier, Vec::new()),
9184 &self.operation_cancelled,
9185 )
9186 .map_err(|error| self.map_consensus_error(error))?;
9187 self.persist_entry(&entry, slot, last_hash)?;
9188 }
9192 DecisionInspection::Empty => {
9193 let entry = self
9194 .consensus
9195 .propose_at_cancellable(
9196 slot,
9197 last_hash,
9198 Command::new(CommandKind::ReadBarrier, Vec::new()),
9199 &self.operation_cancelled,
9200 )
9201 .map_err(|error| self.map_consensus_error(error))?;
9202 let is_barrier =
9203 entry.entry_type == EntryType::Noop && entry.payload.is_empty();
9204 self.persist_entry(&entry, slot, last_hash)?;
9205 if is_barrier {
9206 return Ok(LogAnchor::new(entry.index, entry.hash));
9207 }
9208 }
9209 DecisionInspection::Unavailable => {
9210 return Err(NodeError::Unavailable(
9211 "decision inspection did not reach quorum".into(),
9212 ));
9213 }
9214 }
9215 }
9216 }
9217
9218 #[cfg(feature = "sql")]
9219 fn check_request(
9220 &self,
9221 request_id: &str,
9222 payload: &[u8],
9223 ) -> Result<Option<RequestOutcome>, NodeError> {
9224 let sqlite = self.lock_sqlite()?;
9225 sqlite
9226 .check_request(request_id, payload)
9227 .map_err(|error| self.map_sqlite_error(error))
9228 }
9229
9230 #[cfg(feature = "graph")]
9231 #[cfg_attr(
9232 all(not(feature = "sql"), not(feature = "kv")),
9233 allow(irrefutable_let_patterns)
9234 )]
9235 fn check_graph_request(
9236 &self,
9237 request_id: &str,
9238 payload: &[u8],
9239 ) -> Result<Option<GraphRequestRecord>, NodeError> {
9240 let materializer = self.lock_materializer()?;
9241 let Materializer::Graph(graph) = &*materializer else {
9242 return Err(NodeError::ExecutionProfileMismatch {
9243 expected: ExecutionProfile::Graph,
9244 actual: materializer.profile(),
9245 });
9246 };
9247 graph
9248 .check_request(request_id, payload)
9249 .map_err(|error| NodeError::InvalidRequest(error.to_string()))
9250 }
9251
9252 #[cfg(feature = "kv")]
9253 #[cfg_attr(
9254 all(not(feature = "sql"), not(feature = "graph")),
9255 allow(irrefutable_let_patterns)
9256 )]
9257 fn check_kv_request(
9258 &self,
9259 request_id: &str,
9260 payload: &[u8],
9261 ) -> Result<Option<KvRequestRecord>, NodeError> {
9262 let materializer = self.lock_materializer()?;
9263 let Materializer::Kv(kv) = &*materializer else {
9264 return Err(NodeError::ExecutionProfileMismatch {
9265 expected: ExecutionProfile::Kv,
9266 actual: materializer.profile(),
9267 });
9268 };
9269 kv.check_request(request_id, payload)
9270 .map_err(|error| NodeError::InvalidRequest(error.to_string()))
9271 }
9272
9273 fn ensure_materialized_tip(&self) -> Result<(LogIndex, LogHash), NodeError> {
9274 #[cfg(test)]
9275 self.materialized_tip_checks.fetch_add(1, Ordering::Relaxed);
9276 let (last_index, last_hash) = self.durable_tip()?;
9277 let materializer = self.lock_materializer()?;
9278 let applied_tip = materializer
9279 .applied_tip()
9280 .map_err(|error| self.latch(NodeError::Storage(error)))?;
9281 let applied_index = applied_tip.index();
9282 let applied_hash = applied_tip.hash();
9283 if (applied_index, applied_hash) != (last_index, last_hash) {
9284 return Err(self.latch(NodeError::Invariant(format!(
9285 "qlog tip {last_index}/{} differs from {} materializer tip {applied_index}/{}",
9286 last_hash.to_hex(),
9287 materializer.profile(),
9288 applied_hash.to_hex()
9289 ))));
9290 }
9291 Ok((last_index, last_hash))
9292 }
9293
9294 fn durable_tip(&self) -> Result<(LogIndex, LogHash), NodeError> {
9295 static_log_tip(&self.log_store).map_err(|error| self.latch(error))
9296 }
9297
9298 fn persist_entry(
9299 &self,
9300 entry: &LogEntry,
9301 expected_index: LogIndex,
9302 expected_prev_hash: LogHash,
9303 ) -> Result<Option<SqlCommandResult>, NodeError> {
9304 let configuration_state = self.configuration_state()?;
9305 validate_runtime_entry(
9306 &self.config,
9307 &configuration_state,
9308 entry,
9309 expected_index,
9310 expected_prev_hash,
9311 )
9312 .map_err(|error| self.latch(error))?;
9313 if matches!(
9314 self.config.execution_profile,
9315 ExecutionProfile::Sqlite | ExecutionProfile::Kv
9316 ) {
9317 self.log_store
9320 .append_batch_buffered(std::slice::from_ref(entry))
9321 .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9322 } else {
9323 self.log_store
9324 .append(entry)
9325 .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9326 }
9327 self.lock_materializer()?
9328 .apply_entry(entry)
9329 .map_err(|error| self.latch(NodeError::Invariant(error)))
9330 }
9331
9332 #[cfg(feature = "sql")]
9333 fn persist_sql_entry_profiled<P: SqlWritePhaseProfile>(
9334 &self,
9335 entry: &LogEntry,
9336 expected_index: LogIndex,
9337 expected_prev_hash: LogHash,
9338 profile: &mut P,
9339 ) -> Result<Option<SqlCommandResult>, NodeError> {
9340 let configuration_state = self.configuration_state()?;
9341 validate_runtime_entry(
9342 &self.config,
9343 &configuration_state,
9344 entry,
9345 expected_index,
9346 expected_prev_hash,
9347 )
9348 .map_err(|error| self.latch(error))?;
9349
9350 let qlog_mark = profile.mark();
9351 let append_result = self
9352 .log_store
9353 .append_batch_buffered(std::slice::from_ref(entry))
9354 .map_err(|error| self.latch(NodeError::Storage(error.to_string())));
9355 profile.add_local_qlog_mirror_append(qlog_mark);
9356 append_result?;
9357
9358 let materializer_mark = profile.mark();
9359 let apply_result = self
9360 .lock_materializer()?
9361 .apply_entry(entry)
9362 .map_err(|error| self.latch(NodeError::Invariant(error)));
9363 profile.add_sql_materializer_apply(materializer_mark);
9364 apply_result
9365 }
9366
9367 fn require_execution_profile(&self, expected: ExecutionProfile) -> Result<(), NodeError> {
9368 if self.config.execution_profile == expected {
9369 Ok(())
9370 } else {
9371 Err(NodeError::ExecutionProfileMismatch {
9372 expected,
9373 actual: self.config.execution_profile,
9374 })
9375 }
9376 }
9377
9378 fn ensure_ready(&self) -> Result<(), NodeError> {
9379 if self.fatal.load(Ordering::Acquire) {
9380 return Err(NodeError::Fatal(
9381 self.fatal_reason()
9382 .unwrap_or_else(|| "fatal state is latched".into()),
9383 ));
9384 }
9385 if !self.ready.load(Ordering::Acquire) {
9386 return Err(NodeError::Unavailable("runtime is not ready".into()));
9387 }
9388 Ok(())
9389 }
9390
9391 fn ensure_writes_active(&self) -> Result<(), NodeError> {
9392 let state = self.configuration_state()?;
9393 if state.is_active() {
9394 Ok(())
9395 } else {
9396 Err(NodeError::ConfigurationTransition {
9397 state: Box::new(state),
9398 })
9399 }
9400 }
9401
9402 fn lock_commit(&self) -> Result<MutexGuard<'_, ()>, NodeError> {
9403 self.commit
9404 .lock()
9405 .map_err(|_| self.latch(NodeError::Invariant("commit mutex is poisoned".into())))
9406 }
9407
9408 fn lock_materializer(&self) -> Result<MutexGuard<'_, Materializer>, NodeError> {
9409 self.materializer.lock().map_err(|_| {
9410 self.latch(NodeError::Invariant(
9411 "materializer mutex is poisoned".into(),
9412 ))
9413 })
9414 }
9415
9416 #[cfg(feature = "graph")]
9417 #[cfg_attr(
9418 all(not(feature = "sql"), not(feature = "kv")),
9419 allow(irrefutable_let_patterns)
9420 )]
9421 fn graph_materializer(&self) -> Result<Arc<LadybugStateMachine>, NodeError> {
9422 let materializer = self.lock_materializer()?;
9423 let Materializer::Graph(graph) = &*materializer else {
9424 return Err(NodeError::ExecutionProfileMismatch {
9425 expected: ExecutionProfile::Graph,
9426 actual: materializer.profile(),
9427 });
9428 };
9429 Ok(Arc::clone(graph))
9430 }
9431
9432 #[cfg(feature = "kv")]
9433 #[cfg_attr(
9434 all(not(feature = "sql"), not(feature = "graph")),
9435 allow(irrefutable_let_patterns)
9436 )]
9437 fn kv_materializer(&self) -> Result<Arc<RedbStateMachine>, NodeError> {
9438 let materializer = self.lock_materializer()?;
9439 let Materializer::Kv(kv) = &*materializer else {
9440 return Err(NodeError::ExecutionProfileMismatch {
9441 expected: ExecutionProfile::Kv,
9442 actual: materializer.profile(),
9443 });
9444 };
9445 Ok(Arc::clone(kv))
9446 }
9447
9448 #[cfg(feature = "sql")]
9449 fn lock_sqlite(&self) -> Result<SqlMaterializerGuard<'_>, NodeError> {
9450 let guard = self.lock_materializer()?;
9451 if !matches!(&*guard, Materializer::Sql(_)) {
9452 return Err(NodeError::ExecutionProfileMismatch {
9453 expected: ExecutionProfile::Sqlite,
9454 actual: guard.profile(),
9455 });
9456 }
9457 Ok(SqlMaterializerGuard(guard))
9458 }
9459
9460 #[cfg(feature = "sql")]
9461 fn map_sqlite_error(&self, error: rhiza_sql::Error) -> NodeError {
9462 match error {
9463 rhiza_sql::Error::RequestConflict(conflict) => NodeError::RequestConflict(conflict),
9464 rhiza_sql::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9465 rhiza_sql::Error::InvalidCommand(message)
9466 | rhiza_sql::Error::IdentityMismatch(message)
9467 | rhiza_sql::Error::InvalidEntry(message)
9468 | rhiza_sql::Error::InvalidSnapshot(message) => {
9469 self.latch(NodeError::Invariant(message))
9470 }
9471 other => self.latch(NodeError::Storage(other.to_string())),
9472 }
9473 }
9474
9475 #[cfg(feature = "graph")]
9476 fn map_graph_read_error(&self, error: rhiza_graph::Error) -> NodeError {
9477 match error {
9478 rhiza_graph::Error::InvalidCommand(_) => NodeError::InvalidRequest(error.to_string()),
9479 rhiza_graph::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9480 rhiza_graph::Error::Ladybug(_) | rhiza_graph::Error::Io(_) => {
9481 self.latch(NodeError::Storage(error.to_string()))
9482 }
9483 rhiza_graph::Error::Closed
9484 | rhiza_graph::Error::Codec(_)
9485 | rhiza_graph::Error::InvalidEntry(_)
9486 | rhiza_graph::Error::IdentityMismatch(_)
9487 | rhiza_graph::Error::RequestConflict { .. }
9488 | rhiza_graph::Error::InvalidSnapshot(_) => {
9489 self.latch(NodeError::Invariant(error.to_string()))
9490 }
9491 }
9492 }
9493
9494 #[cfg(feature = "kv")]
9495 fn map_kv_read_error(&self, error: rhiza_kv::Error) -> NodeError {
9496 match error {
9497 rhiza_kv::Error::InvalidCommand(_) | rhiza_kv::Error::InvalidQuery(_) => {
9498 NodeError::InvalidRequest(error.to_string())
9499 }
9500 rhiza_kv::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9501 rhiza_kv::Error::Database(_) | rhiza_kv::Error::Io(_) => {
9502 self.latch(NodeError::Storage(error.to_string()))
9503 }
9504 rhiza_kv::Error::Codec(_)
9505 | rhiza_kv::Error::InvalidEntry(_)
9506 | rhiza_kv::Error::PartialInitialization
9507 | rhiza_kv::Error::RequestConflict { .. }
9508 | rhiza_kv::Error::InvalidSnapshot(_) => {
9509 self.latch(NodeError::Invariant(error.to_string()))
9510 }
9511 }
9512 }
9513
9514 fn map_consensus_error(&self, error: rhiza_quepaxa::Error) -> NodeError {
9515 match error {
9516 rhiza_quepaxa::Error::NoQuorum
9517 | rhiza_quepaxa::Error::ProposeFailed
9518 | rhiza_quepaxa::Error::CommandUnavailable
9519 | rhiza_quepaxa::Error::Cancelled
9520 | rhiza_quepaxa::Error::Io(_) => NodeError::Unavailable(error.to_string()),
9521 rhiza_quepaxa::Error::ConflictingCertificates
9522 | rhiza_quepaxa::Error::ChainConflict { .. } => {
9523 self.latch(NodeError::Reconciliation(error.to_string()))
9524 }
9525 other => self.latch(NodeError::Invariant(other.to_string())),
9526 }
9527 }
9528
9529 fn latch(&self, error: NodeError) -> NodeError {
9530 self.ready.store(false, Ordering::Release);
9531 if !self.fatal.swap(true, Ordering::AcqRel) {
9532 eprintln!(
9533 "node runtime entered fatal state: {}",
9534 escaped_error_detail(&error)
9535 );
9536 if let Ok(mut reason) = self.fatal_reason.lock() {
9537 *reason = Some(error.to_string());
9538 }
9539 }
9540 error
9541 }
9542}
9543
9544pub fn rehydrate_recorder_after_checkpoint(
9545 runtime: &NodeRuntime,
9546 recorder: &RecorderFileStore,
9547 checkpoint_index: LogIndex,
9548 startup: &StartupIoContext,
9549) -> Result<(), NodeError> {
9550 startup.check("recorder rehydration anchor validation")?;
9551 if let Some(anchor) = runtime
9552 .log_store()
9553 .logical_state()
9554 .map_err(|error| NodeError::Storage(error.to_string()))?
9555 .anchor
9556 {
9557 if checkpoint_index < anchor.compacted().index() {
9558 return Err(NodeError::SnapshotRequired(Box::new(anchor)));
9559 }
9560 }
9561 let applied_index = runtime.applied_index()?;
9562 if checkpoint_index > applied_index {
9563 return Err(NodeError::Reconciliation(format!(
9564 "checkpoint tip {checkpoint_index} is ahead of local applied index {applied_index}"
9565 )));
9566 }
9567
9568 for index in checkpoint_index.saturating_add(1)..=applied_index {
9569 startup.check("recorder rehydration qlog read")?;
9570 let entry = runtime
9571 .log_store()
9572 .read(index)
9573 .map_err(|error| NodeError::Storage(error.to_string()))?
9574 .ok_or_else(|| {
9575 NodeError::Reconciliation(format!(
9576 "qlog entry {index} is missing during recorder rehydration"
9577 ))
9578 })?;
9579 startup.check("recorder rehydration decision inspection")?;
9580 let certified = match runtime
9581 .consensus()
9582 .inspect_certified_decision_at(index, entry.prev_hash)
9583 .map_err(startup_consensus_error)?
9584 {
9585 CertifiedDecisionInspection::Committed(certified) => certified,
9586 CertifiedDecisionInspection::Empty => {
9587 return Err(NodeError::Reconciliation(format!(
9588 "qlog entry {index} has no recorder decision certificate"
9589 )))
9590 }
9591 CertifiedDecisionInspection::Pending => {
9592 return Err(NodeError::Reconciliation(format!(
9593 "qlog entry {index} has only a pending recorder decision"
9594 )))
9595 }
9596 CertifiedDecisionInspection::Unavailable => {
9597 return Err(NodeError::Unavailable(format!(
9598 "recorder decision certificate is unavailable at qlog index {index}"
9599 )))
9600 }
9601 };
9602 startup.check("recorder rehydration decision inspection")?;
9603 if certified.entry != entry {
9604 return Err(NodeError::Reconciliation(format!(
9605 "recorder decision certificate differs from qlog entry {index}"
9606 )));
9607 }
9608 let command = StoredCommand::new(entry.entry_type, entry.payload.clone());
9609 let proof = certified.proof.clone();
9610 startup.persist("recorder rehydration persistence", || {
9611 recorder
9612 .store_command(command.hash(), command)
9613 .map_err(|error| {
9614 NodeError::Reconciliation(format!(
9615 "cannot restore recorder command at qlog index {index}: {error}"
9616 ))
9617 })?;
9618 recorder
9619 .install_decision_proof_record(proof, runtime.consensus().membership())
9620 .map_err(|error| {
9621 NodeError::Reconciliation(format!(
9622 "cannot install recorder decision at qlog index {index}: {error}"
9623 ))
9624 })
9625 })?;
9626 }
9627 Ok(())
9628}
9629
9630#[cfg(test)]
9631mod tests {
9632 use axum::http::HeaderValue;
9633
9634 use rhiza_core::{
9635 Command, CommandKind, ConfigurationState, EntryType, ErrorCategory, ErrorClassification,
9636 ExecutionProfile, LogAnchor, LogHash, RecoveryAnchor, SnapshotIdentity, StoredCommand,
9637 };
9638 #[cfg(feature = "graph")]
9639 use rhiza_graph::{GraphCommandV1, GraphValueV1};
9640 #[cfg(feature = "kv")]
9641 use rhiza_kv::KvCommandV1;
9642 use rhiza_log::LogStore as _;
9643 use rhiza_quepaxa::{
9644 AcceptedValue, Membership, Proposal, ProposalPriority, RecordRequest, RecordSummary,
9645 RecorderFileStore, RecorderRpc, ThreeNodeConsensus,
9646 };
9647 use std::sync::{
9648 atomic::{AtomicBool, AtomicUsize, Ordering},
9649 mpsc, Arc, Barrier, Condvar, Mutex,
9650 };
9651
9652 #[cfg(any(feature = "sql", feature = "graph", feature = "kv"))]
9653 use super::node_error_response;
9654 #[cfg(feature = "graph")]
9655 use super::with_graph_client_permit;
9656 use super::ReadBarrierRounds;
9657 use super::{
9658 client_authenticated, next_sync_flush_retry, run_read_operation, sql_query_http_response,
9659 valid_recorder_record, Duration, FileLogStore, HeaderMap, Instant, NodeError,
9660 ReadConsistency, SqlCommand, SqlQueryResponse, SqlStatement, SqlValue, SqlWriteProfiler,
9661 MAX_COMMAND_BYTES, MAX_SQL_RESPONSE_BYTES, PROTOCOL_VERSION, QWAL_V3_MAGIC,
9662 SYNC_FLUSH_RETRY_INITIAL, VERSION_HEADER,
9663 };
9664 use super::{ConfigError, NodeConfig, NodeRuntime, NodeService, StartupIoContext};
9665
9666 struct BlockingStartupInspection {
9667 recorder: RecorderFileStore,
9668 started: mpsc::SyncSender<()>,
9669 gate: Arc<(Mutex<bool>, Condvar)>,
9670 }
9671
9672 impl RecorderRpc for BlockingStartupInspection {
9673 fn recorder_id(&self) -> rhiza_quepaxa::Result<String> {
9674 self.recorder.recorder_id()
9675 }
9676
9677 fn inspect_record_summary(
9678 &self,
9679 slot: u64,
9680 ) -> rhiza_quepaxa::Result<Option<RecordSummary>> {
9681 self.started.send(()).unwrap();
9682 let (released, condition) = &*self.gate;
9683 let mut released = released.lock().unwrap();
9684 while !*released {
9685 released = condition.wait(released).unwrap();
9686 }
9687 self.recorder.inspect_record_summary(slot)
9688 }
9689
9690 fn fetch_command_for(
9691 &self,
9692 cluster_id: String,
9693 epoch: u64,
9694 config_id: u64,
9695 config_digest: LogHash,
9696 command_hash: LogHash,
9697 ) -> rhiza_quepaxa::Result<Option<StoredCommand>> {
9698 self.recorder.fetch_command_for(
9699 cluster_id,
9700 epoch,
9701 config_id,
9702 config_digest,
9703 command_hash,
9704 )
9705 }
9706 }
9707
9708 #[test]
9709 fn runtime_open_does_not_persist_a_decision_returned_after_cancellation() {
9710 let root = tempfile::tempdir().unwrap();
9711 let node_ids = ["n1", "n2", "n3"];
9712 let membership = Membership::new(node_ids).unwrap();
9713 let recorders = node_ids
9714 .iter()
9715 .map(|node_id| {
9716 (
9717 (*node_id).to_owned(),
9718 RecorderFileStore::new_with_membership(
9719 root.path().join("recorders").join(node_id),
9720 *node_id,
9721 "rhiza:sql:startup-cancel-test",
9722 1,
9723 1,
9724 membership.clone(),
9725 )
9726 .unwrap(),
9727 )
9728 })
9729 .collect::<Vec<_>>();
9730 let seed_consensus = ThreeNodeConsensus::from_recorders_with_ids(
9731 "rhiza:sql:startup-cancel-test",
9732 "n1",
9733 1,
9734 1,
9735 recorders
9736 .iter()
9737 .map(|(id, recorder)| {
9738 (
9739 id.clone(),
9740 Box::new(recorder.clone()) as Box<dyn RecorderRpc>,
9741 )
9742 })
9743 .collect(),
9744 )
9745 .unwrap();
9746 seed_consensus
9747 .propose_at(
9748 1,
9749 LogHash::ZERO,
9750 Command::new(CommandKind::ReadBarrier, Vec::new()),
9751 )
9752 .unwrap();
9753 assert!(seed_consensus.finish_pending_rpcs(Duration::from_secs(1)));
9754 drop(seed_consensus);
9755
9756 let (started, blocked) = mpsc::sync_channel(3);
9757 let gate = Arc::new((Mutex::new(false), Condvar::new()));
9758 let consensus = Arc::new(
9759 ThreeNodeConsensus::from_recorders_with_ids(
9760 "rhiza:sql:startup-cancel-test",
9761 "n1",
9762 1,
9763 1,
9764 recorders
9765 .into_iter()
9766 .map(|(id, recorder)| {
9767 (
9768 id,
9769 Box::new(BlockingStartupInspection {
9770 recorder,
9771 started: started.clone(),
9772 gate: Arc::clone(&gate),
9773 }) as Box<dyn RecorderRpc>,
9774 )
9775 })
9776 .collect(),
9777 )
9778 .unwrap(),
9779 );
9780 let config = NodeConfig::new_embedded(
9781 "startup-cancel-test",
9782 "n1",
9783 root.path().join("node"),
9784 1,
9785 1,
9786 node_ids,
9787 )
9788 .unwrap();
9789 let startup = StartupIoContext::new();
9790 let attempt_startup = startup.clone();
9791 let attempt_config = config.clone();
9792 let attempt = std::thread::spawn(move || {
9793 NodeRuntime::open_cancellable(attempt_config, consensus, &[], &attempt_startup)
9794 });
9795
9796 blocked.recv().unwrap();
9797 startup.cancel(Instant::now() + Duration::from_secs(1));
9798 let (released, condition) = &*gate;
9799 *released.lock().unwrap() = true;
9800 condition.notify_all();
9801
9802 let error = attempt.join().unwrap().unwrap_err();
9803 assert!(
9804 error
9805 .to_string()
9806 .contains("startup cancelled during recorder decision inspection"),
9807 "{error}"
9808 );
9809 let log_store = FileLogStore::open_with_configuration(
9810 config.data_dir.join("consensus/log"),
9811 &config.cluster_id,
9812 config.epoch,
9813 config.log_initial_configuration.clone(),
9814 )
9815 .unwrap();
9816 assert_eq!(log_store.last_index().unwrap(), None);
9817 }
9818
9819 #[test]
9820 fn startup_cancellation_closes_mutation_admission_after_an_active_write_finishes() {
9821 let startup = StartupIoContext::new();
9822 let attempt_startup = startup.clone();
9823 let writes = Arc::new(AtomicUsize::new(0));
9824 let attempt_writes = Arc::clone(&writes);
9825 let (entered, active) = mpsc::sync_channel(1);
9826 let gate = Arc::new((Mutex::new(false), Condvar::new()));
9827 let attempt_gate = Arc::clone(&gate);
9828 let attempt = std::thread::spawn(move || {
9829 attempt_startup.persist("test persistence", || {
9830 entered.send(()).unwrap();
9831 let (released, condition) = &*attempt_gate;
9832 let mut released = released.lock().unwrap();
9833 while !*released {
9834 released = condition.wait(released).unwrap();
9835 }
9836 attempt_writes.fetch_add(1, Ordering::AcqRel);
9837 Ok(())
9838 })
9839 });
9840
9841 active.recv().unwrap();
9842 startup.cancel(Instant::now() + Duration::from_secs(1));
9843 let (released, condition) = &*gate;
9844 *released.lock().unwrap() = true;
9845 condition.notify_all();
9846 attempt.join().unwrap().unwrap();
9847
9848 assert!(startup
9849 .persist("late persistence", || {
9850 writes.fetch_add(1, Ordering::AcqRel);
9851 Ok(())
9852 })
9853 .is_err());
9854 assert_eq!(writes.load(Ordering::Acquire), 1);
9855 }
9856
9857 #[test]
9858 fn embedded_config_accepts_matching_canonical_profile_ids() {
9859 for (cluster_id, profile) in [
9860 ("rhiza:graph:embedded", ExecutionProfile::Graph),
9861 ("rhiza:kv:embedded", ExecutionProfile::Kv),
9862 ] {
9863 let config = NodeConfig::new_embedded(
9864 cluster_id,
9865 "n1",
9866 std::env::temp_dir().join(profile.as_str()),
9867 1,
9868 1,
9869 ["n1", "n2", "n3"],
9870 )
9871 .unwrap()
9872 .with_execution_profile(profile)
9873 .unwrap();
9874
9875 assert_eq!(config.cluster_id(), cluster_id);
9876 assert_eq!(config.logical_cluster_id(), "embedded");
9877 }
9878 }
9879
9880 #[test]
9881 fn embedded_config_rejects_conflicting_canonical_profile_and_preserves_logical_ids() {
9882 let conflicting = NodeConfig::new_embedded(
9883 "rhiza:graph:embedded",
9884 "n1",
9885 std::env::temp_dir().join("conflicting-profile"),
9886 1,
9887 1,
9888 ["n1", "n2", "n3"],
9889 )
9890 .unwrap()
9891 .with_execution_profile(ExecutionProfile::Sqlite)
9892 .unwrap_err();
9893 assert!(matches!(
9894 conflicting,
9895 ConfigError::ClusterIdProfileMismatch {
9896 expected: ExecutionProfile::Sqlite,
9897 actual: ExecutionProfile::Graph,
9898 }
9899 ));
9900
9901 let logical = NodeConfig::new_embedded(
9902 "embedded",
9903 "n1",
9904 std::env::temp_dir().join("logical-profile"),
9905 1,
9906 1,
9907 ["n1", "n2", "n3"],
9908 )
9909 .unwrap();
9910 assert_eq!(logical.cluster_id(), "rhiza:sql:embedded");
9911 assert_eq!(logical.logical_cluster_id(), "embedded");
9912 }
9913
9914 #[test]
9915 fn node_error_classification_reports_observable_retry_semantics() {
9916 let cases = [
9917 (
9918 NodeError::InvalidRequest("missing key".into()),
9919 "invalid_request",
9920 ErrorCategory::InvalidRequest,
9921 false,
9922 ),
9923 (
9924 NodeError::PreconditionFailed("stale version".into()),
9925 "precondition_failed",
9926 ErrorCategory::Conflict,
9927 false,
9928 ),
9929 (
9930 NodeError::Unavailable("no quorum".into()),
9931 "unavailable",
9932 ErrorCategory::Unavailable,
9933 true,
9934 ),
9935 (
9936 NodeError::ResourceExhausted("result too large".into()),
9937 "resource_exhausted",
9938 ErrorCategory::ResourceExhausted,
9939 true,
9940 ),
9941 (
9942 NodeError::Invariant("invalid log".into()),
9943 "invariant_violation",
9944 ErrorCategory::Internal,
9945 false,
9946 ),
9947 ];
9948
9949 for (error, code, category, retryable) in cases {
9950 let classification = error.classification();
9951
9952 assert_eq!(classification.code(), code);
9953 assert_eq!(classification.category(), category);
9954 assert_eq!(classification.retryable(), retryable);
9955 }
9956 }
9957
9958 #[cfg(feature = "sql")]
9959 #[test]
9960 fn sql_batch_error_classification_preserves_statement_index_category() {
9961 let error = NodeError::InvalidSqlStatement {
9962 statement_index: 3,
9963 message: "syntax error".into(),
9964 };
9965
9966 let classification = error.classification();
9967
9968 assert_eq!(classification.code(), "invalid_request");
9969 assert_eq!(classification.category(), ErrorCategory::InvalidRequest);
9970 assert!(!classification.retryable());
9971 }
9972
9973 #[cfg(feature = "sql")]
9974 #[tokio::test]
9975 async fn node_error_http_response_preserves_contract() {
9976 let snapshot = RecoveryAnchor::new(
9977 "cluster",
9978 1,
9979 ConfigurationState::active(1, LogHash::digest(&[b"node-error-test-config"])),
9980 1,
9981 LogAnchor::new(1, LogHash::ZERO),
9982 SnapshotIdentity::new(
9983 "snapshot",
9984 LogHash::digest(&[b"node-error-test-snapshot"]),
9985 0,
9986 rhiza_sql::sql_executor_fingerprint().unwrap(),
9987 ),
9988 );
9989 let cases = vec![
9990 (
9991 NodeError::InvalidSqlStatement {
9992 statement_index: 3,
9993 message: "syntax error".into(),
9994 },
9995 axum::http::StatusCode::BAD_REQUEST,
9996 "invalid_request",
9997 false,
9998 Some(3),
9999 ),
10000 (
10001 NodeError::PreconditionFailed("stale version".into()),
10002 axum::http::StatusCode::CONFLICT,
10003 "precondition_failed",
10004 false,
10005 None,
10006 ),
10007 (
10008 NodeError::SnapshotRequired(Box::new(snapshot)),
10009 axum::http::StatusCode::SERVICE_UNAVAILABLE,
10010 "snapshot_required",
10011 false,
10012 None,
10013 ),
10014 (
10015 NodeError::Storage("disk failed".into()),
10016 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
10017 "storage_error",
10018 false,
10019 None,
10020 ),
10021 ];
10022
10023 for (node_error, status, code, retryable, statement_index) in cases {
10024 let response = node_error_response(node_error);
10025 assert_eq!(response.status(), status);
10026 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10027 .await
10028 .unwrap();
10029 let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
10030 let error: super::ClientErrorResponse = serde_json::from_value(value.clone()).unwrap();
10031 assert_eq!(error.code, code);
10032 assert_eq!(error.retryable, retryable);
10033 assert_eq!(error.statement_index, statement_index);
10034 assert!(value.get("category").is_none());
10035 assert!(matches!(
10036 error.message.as_str(),
10037 "request could not be processed"
10038 | "request conflicts with current state"
10039 | "service is temporarily unavailable"
10040 | "internal server error"
10041 ));
10042 }
10043 }
10044
10045 #[cfg(feature = "sql")]
10046 #[tokio::test]
10047 async fn node_error_http_response_hides_display_details() {
10048 let detail = "/srv/rhiza/private/consensus/log: checksum mismatch";
10049 let cases = [
10050 (
10051 NodeError::Storage(detail.into()),
10052 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
10053 "storage_error",
10054 None,
10055 "internal server error",
10056 ),
10057 (
10058 NodeError::Reconciliation(detail.into()),
10059 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
10060 "reconciliation_error",
10061 None,
10062 "internal server error",
10063 ),
10064 (
10065 NodeError::InvalidSqlStatement {
10066 statement_index: 7,
10067 message: detail.into(),
10068 },
10069 axum::http::StatusCode::BAD_REQUEST,
10070 "invalid_request",
10071 Some(7),
10072 "request could not be processed",
10073 ),
10074 ];
10075
10076 for (node_error, status, code, statement_index, message) in cases {
10077 let response = node_error_response(node_error);
10078 assert_eq!(response.status(), status);
10079 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10080 .await
10081 .unwrap();
10082 let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10083 assert_eq!(error.code, code);
10084 assert_eq!(error.statement_index, statement_index);
10085 assert_eq!(error.message, message);
10086 assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
10087 }
10088 }
10089
10090 #[tokio::test]
10091 async fn client_json_error_response_uses_a_stable_message() {
10092 for (status, code) in [
10093 (axum::http::StatusCode::BAD_REQUEST, "invalid_json"),
10094 (
10095 axum::http::StatusCode::PAYLOAD_TOO_LARGE,
10096 "payload_too_large",
10097 ),
10098 ] {
10099 let response = super::client_json_error_response(status);
10100
10101 assert_eq!(response.status(), status);
10102 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10103 .await
10104 .unwrap();
10105 let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10106 assert_eq!(error.code, code);
10107 assert!(!error.retryable);
10108 assert_eq!(error.message, "request body is invalid");
10109 assert_eq!(error.statement_index, None);
10110 }
10111 }
10112
10113 #[tokio::test]
10114 async fn client_task_error_hides_join_error_details() {
10115 let detail = "private task detail\nforged log entry";
10116 let error = tokio::spawn(async move { panic!("{detail}") })
10117 .await
10118 .unwrap_err();
10119 let response = super::client_task_error(error);
10120
10121 assert_eq!(
10122 response.status(),
10123 axum::http::StatusCode::INTERNAL_SERVER_ERROR
10124 );
10125 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10126 .await
10127 .unwrap();
10128 let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10129 assert_eq!(error.code, "task_failed");
10130 assert!(!error.retryable);
10131 assert_eq!(error.message, "request task failed");
10132 assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
10133 }
10134
10135 #[cfg(feature = "sql")]
10136 #[tokio::test]
10137 async fn fatal_readiness_response_hides_fatal_reason() {
10138 let (_dir, runtime) = sql_test_runtime();
10139 let detail = "/srv/rhiza/private\nforged log entry";
10140 runtime.latch(NodeError::Storage(detail.into()));
10141 let response = super::runtime_readiness_response(&runtime).unwrap();
10142
10143 assert_eq!(
10144 response.status(),
10145 axum::http::StatusCode::INTERNAL_SERVER_ERROR
10146 );
10147 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10148 .await
10149 .unwrap();
10150 let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10151 assert_eq!(error.code, "fatal");
10152 assert!(!error.retryable);
10153 assert_eq!(error.message, "node is fatally unavailable");
10154 assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
10155 }
10156
10157 #[test]
10158 fn escaped_error_detail_escapes_control_characters() {
10159 let detail = "checksum mismatch\nforged entry\r\u{1b}[2J";
10160
10161 assert_eq!(
10162 super::escaped_error_detail(&detail),
10163 r"checksum mismatch\nforged entry\r\u{1b}[2J"
10164 );
10165 }
10166
10167 #[test]
10168 fn escaped_error_detail_bounds_escape_expansion() {
10169 let detail = "\n".repeat(super::MAX_ESCAPED_ERROR_DETAIL_BYTES / 2 + 1);
10170 let escaped = super::escaped_error_detail(&detail);
10171
10172 assert!(
10173 escaped.len() <= super::MAX_ESCAPED_ERROR_DETAIL_BYTES,
10174 "escaped detail must stay within the log budget"
10175 );
10176 assert!(
10177 escaped.ends_with(super::ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER),
10178 "truncated details must be explicit"
10179 );
10180 assert!(!escaped.contains('\n'));
10181 }
10182
10183 #[tokio::test]
10184 async fn client_error_responses_preserve_payload_and_authentication_wire_codes() {
10185 for (status, code, retryable, category) in [
10186 (
10187 axum::http::StatusCode::PAYLOAD_TOO_LARGE,
10188 "payload_too_large",
10189 false,
10190 ErrorCategory::ResourceExhausted,
10191 ),
10192 (
10193 axum::http::StatusCode::UNAUTHORIZED,
10194 "unauthorized",
10195 false,
10196 ErrorCategory::Authentication,
10197 ),
10198 ] {
10199 let response =
10200 super::client_error_response(status, code, retryable, "request failed", None);
10201 assert_eq!(response.status(), status);
10202 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10203 .await
10204 .unwrap();
10205 let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
10206 let error: super::ClientErrorResponse = serde_json::from_value(value.clone()).unwrap();
10207 assert_eq!(error.code, code);
10208 assert_eq!(error.retryable, retryable);
10209 assert!(value.get("category").is_none());
10210 assert_eq!(
10211 ErrorClassification::from_server_code(code, retryable).category(),
10212 category
10213 );
10214 }
10215 }
10216
10217 #[test]
10218 fn concurrent_read_barriers_registered_before_cutoff_share_one_generation() {
10219 let rounds = ReadBarrierRounds::new(Duration::ZERO);
10220 let cancelled = AtomicBool::new(false);
10221 let participants = (0..4).map(|_| rounds.join().unwrap()).collect::<Vec<_>>();
10222 let generation = participants[0].generation();
10223 assert!(participants[0].is_leader());
10224 assert!(participants[1..]
10225 .iter()
10226 .all(|participant| !participant.is_leader() && participant.generation() == generation));
10227
10228 let calls = AtomicUsize::new(0);
10229 let mut publication = participants[0].publication().unwrap();
10230 publication.wait_turn(&cancelled).unwrap();
10231 publication.start(&cancelled).unwrap();
10232 let anchor = LogAnchor::new(7, LogHash::digest(&[b"shared-barrier"]));
10233 calls.fetch_add(1, Ordering::Relaxed);
10234 publication.publish(Ok(anchor));
10235
10236 assert_eq!(calls.load(Ordering::Relaxed), 1);
10237 for participant in &participants[1..] {
10238 assert_eq!(participant.wait(&cancelled).unwrap(), anchor);
10239 }
10240 }
10241
10242 #[test]
10243 fn read_barrier_arriving_after_running_cutoff_uses_next_generation() {
10244 let rounds = Arc::new(ReadBarrierRounds::new(Duration::ZERO));
10245 let cancelled = Arc::new(AtomicBool::new(false));
10246 let first = rounds.join().unwrap();
10247 let first_generation = first.generation();
10248 let (running_tx, running_rx) = mpsc::channel();
10249 let (release_tx, release_rx) = mpsc::channel();
10250 let first_cancelled = Arc::clone(&cancelled);
10251 let first_worker = std::thread::spawn(move || {
10252 let mut publication = first.publication().unwrap();
10253 publication.wait_turn(&first_cancelled).unwrap();
10254 publication.start(&first_cancelled).unwrap();
10255 running_tx.send(()).unwrap();
10256 release_rx.recv().unwrap();
10257 let anchor = LogAnchor::new(1, LogHash::digest(&[b"first"]));
10258 publication.publish(Ok(anchor));
10259 anchor
10260 });
10261 running_rx.recv().unwrap();
10262
10263 let late = rounds.join().unwrap();
10264 assert!(late.is_leader());
10265 assert_eq!(late.generation(), first_generation + 1);
10266 release_tx.send(()).unwrap();
10267 assert_eq!(first_worker.join().unwrap().index(), 1);
10268
10269 let mut publication = late.publication().unwrap();
10270 publication.wait_turn(&cancelled).unwrap();
10271 publication.start(&cancelled).unwrap();
10272 let second = LogAnchor::new(2, LogHash::digest(&[b"second"]));
10273 publication.publish(Ok(second));
10274 assert_eq!(late.wait(&cancelled).unwrap(), second);
10275 }
10276
10277 #[test]
10278 fn completed_read_barrier_is_not_reused_and_predecessor_failure_retries_independently() {
10279 let rounds = ReadBarrierRounds::new(Duration::ZERO);
10280 let cancelled = AtomicBool::new(false);
10281 let failed = rounds.join().unwrap();
10282 let failed_generation = failed.generation();
10283 let mut publication = failed.publication().unwrap();
10284 publication.wait_turn(&cancelled).unwrap();
10285 publication.start(&cancelled).unwrap();
10286 publication.publish(Err(NodeError::Unavailable("no quorum".into())));
10287 assert!(matches!(
10288 failed.wait(&cancelled),
10289 Err(NodeError::Unavailable(_))
10290 ));
10291
10292 let retry = rounds.join().unwrap();
10293 assert!(retry.is_leader());
10294 assert_eq!(retry.generation(), failed_generation + 1);
10295 let mut publication = retry.publication().unwrap();
10296 publication.wait_turn(&cancelled).unwrap();
10297 publication.start(&cancelled).unwrap();
10298 let anchor = LogAnchor::new(1, LogHash::digest(&[b"retry"]));
10299 publication.publish(Ok(anchor));
10300 assert_eq!(retry.wait(&cancelled).unwrap(), anchor);
10301
10302 let later = rounds.join().unwrap();
10303 assert!(later.is_leader());
10304 assert_eq!(later.generation(), retry.generation() + 1);
10305 }
10306
10307 #[test]
10308 fn read_barrier_leader_drop_and_global_cancel_wake_waiters() {
10309 let rounds = Arc::new(ReadBarrierRounds::new(Duration::ZERO));
10310 let cancelled = Arc::new(AtomicBool::new(false));
10311 let abandoned = rounds.join().unwrap();
10312 let follower = rounds.join().unwrap();
10313 drop(abandoned.publication().unwrap());
10314 assert!(matches!(
10315 follower.wait(&cancelled),
10316 Err(NodeError::Unavailable(_))
10317 ));
10318
10319 let leader = rounds.join().unwrap();
10320 let waiting = rounds.join().unwrap();
10321 let waiting_cancelled = Arc::clone(&cancelled);
10322 let waiter = std::thread::spawn(move || waiting.wait(&waiting_cancelled));
10323 cancelled.store(true, Ordering::Release);
10324 rounds.cancel_waiters();
10325 assert!(matches!(
10326 waiter.join().unwrap(),
10327 Err(NodeError::Unavailable(_))
10328 ));
10329 drop(leader.publication().unwrap());
10330 }
10331
10332 #[test]
10333 fn sql_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
10334 let (_dir, mut runtime) = sql_test_runtime();
10335 runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
10336 let runtime = Arc::new(runtime);
10337 let start = Arc::new(Barrier::new(4));
10338 let workers = (0..4)
10339 .map(|_| {
10340 let runtime = Arc::clone(&runtime);
10341 let start = Arc::clone(&start);
10342 std::thread::spawn(move || {
10343 start.wait();
10344 runtime.read("missing", ReadConsistency::ReadBarrier)
10345 })
10346 })
10347 .collect::<Vec<_>>();
10348
10349 let responses = workers
10350 .into_iter()
10351 .map(|worker| worker.join().unwrap().unwrap())
10352 .collect::<Vec<_>>();
10353 assert!(responses
10354 .iter()
10355 .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
10356 assert_eq!(runtime.log_store().last_index().unwrap(), None);
10357 }
10358
10359 #[test]
10360 fn read_barrier_anchor_remains_valid_when_materialized_tip_advances() {
10361 let (_dir, runtime) = sql_test_runtime();
10362 let anchor = runtime.establish_read_barrier().unwrap();
10363 let write = runtime.write("request-1", "alpha", "one").unwrap();
10364 assert!(write.applied_index > anchor.index());
10365
10366 let _commit = runtime.lock_commit().unwrap();
10367 runtime.ensure_ready().unwrap();
10368 runtime.ensure_writes_active().unwrap();
10369 runtime
10370 .validate_read_barrier_descendant_locked(anchor)
10371 .unwrap();
10372 let read = runtime.read_local("alpha", Some(anchor.index())).unwrap();
10373
10374 assert_eq!(read.value.as_deref(), Some("one"));
10375 assert_eq!(read.applied_index, write.applied_index);
10376 assert_eq!(read.hash, write.hash);
10377 }
10378
10379 #[test]
10380 fn checkpoint_publication_does_not_close_runtime_readiness_or_writes() {
10381 let (_dir, runtime) = sql_test_runtime();
10382 runtime.checkpointing.store(true, Ordering::Release);
10383
10384 assert!(runtime.is_ready());
10385 runtime.ensure_ready().unwrap();
10386 let write = runtime.write("request-1", "alpha", "one").unwrap();
10387
10388 assert_eq!(write.applied_index, 1);
10389 assert_eq!(
10390 runtime.read("alpha", ReadConsistency::Local).unwrap().value,
10391 Some("one".into())
10392 );
10393 }
10394
10395 #[cfg(feature = "graph")]
10396 #[test]
10397 fn graph_read_barrier_checks_materialized_tip_once_before_snapshot() {
10398 let (_dir, runtime) = graph_test_runtime();
10399
10400 let response = runtime
10401 .get_graph_document("missing", ReadConsistency::ReadBarrier)
10402 .unwrap();
10403
10404 assert_eq!(response.applied_index, 0);
10405 assert_eq!(response.hash, LogHash::ZERO);
10406 assert_eq!(runtime.materialized_tip_checks.load(Ordering::Relaxed), 1);
10407 }
10408
10409 #[cfg(feature = "graph")]
10410 #[test]
10411 fn graph_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
10412 let (_dir, mut runtime) = graph_test_runtime();
10413 runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
10414 let runtime = Arc::new(runtime);
10415 let start = Arc::new(Barrier::new(4));
10416 let workers = (0..4)
10417 .map(|_| {
10418 let runtime = Arc::clone(&runtime);
10419 let start = Arc::clone(&start);
10420 std::thread::spawn(move || {
10421 start.wait();
10422 runtime.get_graph_document("missing", ReadConsistency::ReadBarrier)
10423 })
10424 })
10425 .collect::<Vec<_>>();
10426
10427 let responses = workers
10428 .into_iter()
10429 .map(|worker| worker.join().unwrap().unwrap())
10430 .collect::<Vec<_>>();
10431 assert!(responses
10432 .iter()
10433 .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
10434 assert_eq!(runtime.log_store().last_index().unwrap(), None);
10435 }
10436
10437 #[cfg(feature = "graph")]
10438 #[test]
10439 fn graph_read_barrier_releases_commit_lock_before_backend_snapshot() {
10440 let (_dir, mut runtime) = graph_test_runtime();
10441 let initial = runtime
10442 .mutate_graph(
10443 GraphCommandV1::put_document(
10444 "request-1",
10445 "document-1",
10446 GraphValueV1::String("one".into()),
10447 )
10448 .unwrap(),
10449 )
10450 .unwrap();
10451 let entered = Arc::new(Barrier::new(2));
10452 let release = Arc::new(Barrier::new(2));
10453 runtime.read_barrier_before_snapshot_hook = Some(Arc::new({
10454 let entered = Arc::clone(&entered);
10455 let release = Arc::clone(&release);
10456 move || {
10457 entered.wait();
10458 release.wait();
10459 }
10460 }));
10461 let runtime = Arc::new(runtime);
10462 let reader = {
10463 let runtime = Arc::clone(&runtime);
10464 std::thread::spawn(move || {
10465 runtime.get_graph_document("document-1", ReadConsistency::ReadBarrier)
10466 })
10467 };
10468 entered.wait();
10469 let (advanced_tx, advanced_rx) = mpsc::channel();
10470 let writer = {
10471 let runtime = Arc::clone(&runtime);
10472 std::thread::spawn(move || {
10473 let outcome = runtime
10474 .mutate_graph(
10475 GraphCommandV1::put_document(
10476 "request-2",
10477 "document-1",
10478 GraphValueV1::String("two".into()),
10479 )
10480 .unwrap(),
10481 )
10482 .unwrap();
10483 advanced_tx
10484 .send((outcome.applied_index(), outcome.hash()))
10485 .unwrap();
10486 outcome
10487 })
10488 };
10489
10490 let advanced_before_snapshot = advanced_rx.recv_timeout(Duration::from_secs(2));
10491 release.wait();
10492 let written = writer.join().unwrap();
10493 let read = reader.join().unwrap().unwrap();
10494
10495 assert!(
10496 advanced_before_snapshot.is_ok(),
10497 "graph write must advance while the read is paused before its backend snapshot"
10498 );
10499 assert!(read.applied_index >= initial.applied_index());
10500 if read.applied_index == initial.applied_index() {
10501 assert_eq!(read.hash, initial.hash());
10502 }
10503 assert_eq!(read.applied_index, written.applied_index());
10504 assert_eq!(read.hash, written.hash());
10505 }
10506
10507 #[cfg(feature = "graph")]
10508 #[test]
10509 fn read_barrier_rejects_same_index_snapshot_with_different_hash() {
10510 let (_dir, runtime) = graph_test_runtime();
10511 let anchor = LogAnchor::new(7, LogHash::digest(&[b"barrier-anchor"]));
10512 let observed = LogAnchor::new(7, LogHash::digest(&[b"divergent-snapshot"]));
10513
10514 assert!(matches!(
10515 runtime.validate_read_barrier_snapshot(anchor, observed),
10516 Err(NodeError::Invariant(message))
10517 if message.contains("snapshot tip hash differs")
10518 ));
10519 assert!(runtime.is_fatal());
10520 }
10521
10522 #[cfg(feature = "kv")]
10523 #[test]
10524 fn kv_read_barrier_checks_materialized_tip_once_before_snapshot() {
10525 let (_dir, runtime) = kv_test_runtime();
10526
10527 let response = runtime
10528 .get_kv(b"missing", ReadConsistency::ReadBarrier)
10529 .unwrap();
10530
10531 assert_eq!(response.applied_index, 0);
10532 assert_eq!(response.hash, LogHash::ZERO);
10533 assert_eq!(runtime.materialized_tip_checks.load(Ordering::Relaxed), 1);
10534 }
10535
10536 #[cfg(feature = "kv")]
10537 #[test]
10538 fn kv_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
10539 let (_dir, mut runtime) = kv_test_runtime();
10540 runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
10541 let runtime = Arc::new(runtime);
10542 let start = Arc::new(Barrier::new(4));
10543 let workers = (0..4)
10544 .map(|_| {
10545 let runtime = Arc::clone(&runtime);
10546 let start = Arc::clone(&start);
10547 std::thread::spawn(move || {
10548 start.wait();
10549 runtime.get_kv(b"missing", ReadConsistency::ReadBarrier)
10550 })
10551 })
10552 .collect::<Vec<_>>();
10553
10554 let responses = workers
10555 .into_iter()
10556 .map(|worker| worker.join().unwrap().unwrap())
10557 .collect::<Vec<_>>();
10558 assert!(responses
10559 .iter()
10560 .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
10561 assert_eq!(runtime.log_store().last_index().unwrap(), None);
10562 }
10563
10564 #[cfg(feature = "kv")]
10565 #[test]
10566 fn kv_read_barrier_releases_commit_lock_before_backend_snapshot() {
10567 let (_dir, mut runtime) = kv_test_runtime();
10568 let initial = runtime
10569 .mutate_kv(KvCommandV1::put("request-1", b"key".to_vec(), b"one".to_vec()).unwrap())
10570 .unwrap();
10571 let entered = Arc::new(Barrier::new(2));
10572 let release = Arc::new(Barrier::new(2));
10573 runtime.read_barrier_before_snapshot_hook = Some(Arc::new({
10574 let entered = Arc::clone(&entered);
10575 let release = Arc::clone(&release);
10576 move || {
10577 entered.wait();
10578 release.wait();
10579 }
10580 }));
10581 let runtime = Arc::new(runtime);
10582 let reader = {
10583 let runtime = Arc::clone(&runtime);
10584 std::thread::spawn(move || runtime.get_kv(b"key", ReadConsistency::ReadBarrier))
10585 };
10586 entered.wait();
10587 let (advanced_tx, advanced_rx) = mpsc::channel();
10588 let writer = {
10589 let runtime = Arc::clone(&runtime);
10590 std::thread::spawn(move || {
10591 let outcome = runtime
10592 .mutate_kv(
10593 KvCommandV1::put("request-2", b"key".to_vec(), b"two".to_vec()).unwrap(),
10594 )
10595 .unwrap();
10596 advanced_tx
10597 .send((outcome.applied_index(), outcome.hash()))
10598 .unwrap();
10599 outcome
10600 })
10601 };
10602
10603 let advanced_before_snapshot = advanced_rx.recv_timeout(Duration::from_secs(2));
10604 release.wait();
10605 let written = writer.join().unwrap();
10606 let read = reader.join().unwrap().unwrap();
10607
10608 assert!(
10609 advanced_before_snapshot.is_ok(),
10610 "KV write must advance while the read is paused before its backend snapshot"
10611 );
10612 assert!(read.applied_index >= initial.applied_index());
10613 if read.applied_index == initial.applied_index() {
10614 assert_eq!(read.hash, initial.hash());
10615 }
10616 assert_eq!(read.applied_index, written.applied_index());
10617 assert_eq!(read.hash, written.hash());
10618 }
10619
10620 #[test]
10621 fn client_authentication_rejects_empty_expected_token() {
10622 let mut headers = HeaderMap::new();
10623 headers.insert(VERSION_HEADER, HeaderValue::from_static(PROTOCOL_VERSION));
10624 headers.insert("authorization", HeaderValue::from_static("Bearer "));
10625
10626 assert!(!client_authenticated(&headers, ""));
10627 }
10628
10629 #[test]
10630 fn recorder_record_rejects_oversized_inline_command() {
10631 let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
10632 let command = StoredCommand::new(
10633 EntryType::Command,
10634 vec![0_u8; MAX_COMMAND_BYTES.saturating_add(1)],
10635 );
10636 let request = RecordRequest {
10637 cluster_id: "rhiza:sql:node-unit-test".into(),
10638 epoch: 1,
10639 config_id: 1,
10640 config_digest: membership.digest(),
10641 slot: 1,
10642 step: 1,
10643 proposal: Proposal::new(
10644 ProposalPriority::MAX,
10645 "n1",
10646 1,
10647 AcceptedValue::from_command(
10648 "rhiza:sql:node-unit-test",
10649 1,
10650 1,
10651 1,
10652 LogHash::ZERO,
10653 &command,
10654 ),
10655 ),
10656 command: Some(command),
10657 };
10658
10659 assert!(!valid_recorder_record(&request));
10660 }
10661
10662 #[test]
10663 fn sync_flush_retry_doubles_to_a_jitter_free_cap() {
10664 let mut delay = SYNC_FLUSH_RETRY_INITIAL;
10665 let mut delays = Vec::new();
10666 for _ in 0..7 {
10667 delays.push(delay);
10668 delay = next_sync_flush_retry(delay);
10669 }
10670
10671 assert_eq!(
10672 delays,
10673 [50, 100, 200, 400, 800, 1_000, 1_000].map(Duration::from_millis)
10674 );
10675 }
10676
10677 #[test]
10678 fn blocking_operation_offloads_on_current_thread_runtime() {
10679 let runtime = tokio::runtime::Builder::new_current_thread()
10680 .build()
10681 .unwrap();
10682 runtime.block_on(async {
10683 let caller = std::thread::current().id();
10684 let worker = run_read_operation(ReadConsistency::Local, || std::thread::current().id())
10685 .await
10686 .unwrap();
10687
10688 assert_ne!(worker, caller);
10689 });
10690 }
10691
10692 #[test]
10693 fn blocking_operation_runs_inline_on_multi_thread_runtime() {
10694 let runtime = tokio::runtime::Builder::new_multi_thread()
10695 .worker_threads(2)
10696 .build()
10697 .unwrap();
10698 runtime.block_on(async {
10699 let caller = std::thread::current().id();
10700 let worker = run_read_operation(ReadConsistency::AppliedIndex(1), || {
10701 std::thread::current().id()
10702 })
10703 .await
10704 .unwrap();
10705
10706 assert_eq!(worker, caller);
10707 });
10708 }
10709
10710 #[test]
10711 fn read_barrier_offloads_on_multi_thread_runtime() {
10712 let runtime = tokio::runtime::Builder::new_multi_thread()
10713 .worker_threads(2)
10714 .build()
10715 .unwrap();
10716 runtime.block_on(async {
10717 let caller = std::thread::current().id();
10718 let worker =
10719 run_read_operation(ReadConsistency::ReadBarrier, || std::thread::current().id())
10720 .await
10721 .unwrap();
10722
10723 assert_ne!(worker, caller);
10724 });
10725 }
10726
10727 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10728 async fn node_service_adaptive_sql_read_returns_point_and_query_results() {
10729 let (_dir, runtime) = sql_test_runtime();
10730 let service = NodeService::new(Arc::new(runtime), None);
10731
10732 let point = service
10733 .read("missing", ReadConsistency::Local)
10734 .await
10735 .unwrap();
10736 let query = service
10737 .query(
10738 SqlStatement {
10739 sql: "SELECT ?1 AS value".into(),
10740 parameters: vec![SqlValue::Integer(7)],
10741 },
10742 ReadConsistency::AppliedIndex(0),
10743 1,
10744 )
10745 .await
10746 .unwrap();
10747
10748 assert_eq!(point.value, None);
10749 assert_eq!(query.columns, vec!["value"]);
10750 assert_eq!(query.rows, vec![vec![SqlValue::Integer(7)]]);
10751 }
10752
10753 #[test]
10754 fn node_service_adaptive_sql_read_stays_inline_and_recovers_direct_panic() {
10755 let (_dir, runtime) = sql_test_runtime();
10756 let service = NodeService::new(Arc::new(runtime), None);
10757 let runtime = tokio::runtime::Builder::new_multi_thread()
10758 .worker_threads(2)
10759 .build()
10760 .unwrap();
10761 runtime.block_on(async {
10762 let caller = std::thread::current().id();
10763 let worker = service
10764 .run_sql_read_operation(ReadConsistency::Local, || std::thread::current().id())
10765 .await
10766 .unwrap();
10767
10768 assert_eq!(worker, caller);
10769 assert_eq!(
10770 service
10771 .sql_reads_in_flight
10772 .load(std::sync::atomic::Ordering::Acquire),
10773 0
10774 );
10775 });
10776
10777 let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
10778 runtime.block_on(
10779 service.run_sql_read_operation(ReadConsistency::AppliedIndex(0), || -> () {
10780 panic!("inline SQL read panic")
10781 }),
10782 )
10783 }));
10784 assert!(panic.is_err());
10785 assert_eq!(
10786 service
10787 .sql_reads_in_flight
10788 .load(std::sync::atomic::Ordering::Acquire),
10789 0
10790 );
10791 }
10792
10793 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10794 async fn node_service_adaptive_sql_read_offloads_overlap_and_recovers_join_error() {
10795 let (_dir, runtime) = sql_test_runtime();
10796 let service = NodeService::new(Arc::new(runtime), None);
10797 let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
10798 let (release_tx, release_rx) = std::sync::mpsc::channel();
10799 let first_service = service.clone();
10800 let first = tokio::spawn(async move {
10801 first_service
10802 .run_sql_read_operation(ReadConsistency::Local, move || {
10803 entered_tx.send(()).unwrap();
10804 release_rx.recv().unwrap();
10805 })
10806 .await
10807 .unwrap();
10808 });
10809 entered_rx.await.unwrap();
10810 assert_eq!(
10811 service
10812 .sql_reads_in_flight
10813 .load(std::sync::atomic::Ordering::Acquire),
10814 1
10815 );
10816
10817 let caller = std::thread::current().id();
10818 let worker = service
10819 .run_sql_read_operation(ReadConsistency::AppliedIndex(0), || {
10820 std::thread::current().id()
10821 })
10822 .await
10823 .unwrap();
10824 assert_ne!(worker, caller);
10825 assert_eq!(
10826 service
10827 .sql_reads_in_flight
10828 .load(std::sync::atomic::Ordering::Acquire),
10829 1
10830 );
10831
10832 let error = service
10833 .run_sql_read_operation(ReadConsistency::Local, || -> () {
10834 panic!("contended SQL read panic")
10835 })
10836 .await
10837 .unwrap_err();
10838 assert!(error.is_panic());
10839 assert_eq!(
10840 service
10841 .sql_reads_in_flight
10842 .load(std::sync::atomic::Ordering::Acquire),
10843 1
10844 );
10845
10846 release_tx.send(()).unwrap();
10847 first.await.unwrap();
10848 assert_eq!(
10849 service
10850 .sql_reads_in_flight
10851 .load(std::sync::atomic::Ordering::Acquire),
10852 0
10853 );
10854 }
10855
10856 #[test]
10857 fn node_service_adaptive_sql_read_offloads_on_current_thread() {
10858 let (_dir, node) = sql_test_runtime();
10859 let service = NodeService::new(Arc::new(node), None);
10860 let runtime = tokio::runtime::Builder::new_current_thread()
10861 .build()
10862 .unwrap();
10863 runtime.block_on(async {
10864 let caller = std::thread::current().id();
10865 let worker = service
10866 .run_sql_read_operation(ReadConsistency::Local, || std::thread::current().id())
10867 .await
10868 .unwrap();
10869
10870 assert_ne!(worker, caller);
10871 assert_eq!(
10872 service
10873 .sql_reads_in_flight
10874 .load(std::sync::atomic::Ordering::Acquire),
10875 0
10876 );
10877 });
10878 }
10879
10880 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10881 async fn node_service_read_barrier_offloads_without_counting_fast_reads() {
10882 let (_dir, runtime) = sql_test_runtime();
10883 let service = NodeService::new(Arc::new(runtime), None);
10884 let caller = std::thread::current().id();
10885 let worker = service
10886 .run_sql_read_operation(ReadConsistency::ReadBarrier, || std::thread::current().id())
10887 .await
10888 .unwrap();
10889
10890 assert_ne!(worker, caller);
10891 assert_eq!(
10892 service
10893 .sql_reads_in_flight
10894 .load(std::sync::atomic::Ordering::Acquire),
10895 0
10896 );
10897 }
10898
10899 #[test]
10900 fn embedded_sql_query_keeps_raw_budget_without_http_json_budget() {
10901 let (_dir, runtime) = sql_test_runtime();
10902 let response = runtime
10903 .query_sql(
10904 &SqlStatement {
10905 sql: "SELECT replace(hex(zeroblob(700000)), '00', char(1)) AS value".into(),
10906 parameters: Vec::new(),
10907 },
10908 ReadConsistency::Local,
10909 1,
10910 )
10911 .unwrap();
10912
10913 assert!(serde_json::to_vec(&response).unwrap().len() > MAX_SQL_RESPONSE_BYTES);
10914 }
10915
10916 #[test]
10917 fn sql_http_response_rejects_encoded_body_over_limit() {
10918 let response = SqlQueryResponse {
10919 columns: vec!["value".into()],
10920 rows: vec![vec![SqlValue::Text("\u{1}".repeat(700_000))]],
10921 applied_index: 0,
10922 hash: LogHash::ZERO,
10923 };
10924
10925 assert_eq!(
10926 sql_query_http_response(response).status(),
10927 axum::http::StatusCode::BAD_REQUEST
10928 );
10929 }
10930
10931 #[cfg(feature = "graph")]
10932 #[test]
10933 fn graph_response_work_holds_client_capacity_until_completion() {
10934 let slots = std::sync::Arc::new(tokio::sync::Semaphore::new(1));
10935 let permit = std::sync::Arc::new(slots.clone().try_acquire_owned().unwrap());
10936
10937 let capacity_exhausted_during_response =
10938 with_graph_client_permit(permit, || slots.clone().try_acquire_owned().is_err());
10939
10940 assert!(capacity_exhausted_during_response);
10941 assert!(slots.try_acquire().is_ok());
10942 }
10943
10944 #[cfg(feature = "graph")]
10945 #[test]
10946 fn graph_client_query_error_returns_400_without_latching_readiness() {
10947 let (_dir, runtime) = graph_test_runtime();
10948
10949 let error = runtime.map_graph_read_error(rhiza_graph::Error::InvalidCommand(
10950 "unknown property".into(),
10951 ));
10952 let response = node_error_response(error);
10953
10954 assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
10955 assert!(runtime.is_ready());
10956 assert!(!runtime.is_fatal());
10957 }
10958
10959 #[cfg(feature = "graph")]
10960 #[test]
10961 fn graph_resource_exhaustion_returns_503_without_latching_readiness() {
10962 let (_dir, runtime) = graph_test_runtime();
10963
10964 let error = runtime.map_graph_read_error(rhiza_graph::Error::ResourceExhausted(
10965 "buffer pool is full".into(),
10966 ));
10967 let response = node_error_response(error);
10968
10969 assert_eq!(
10970 response.status(),
10971 axum::http::StatusCode::SERVICE_UNAVAILABLE
10972 );
10973 assert!(runtime.is_ready());
10974 assert!(!runtime.is_fatal());
10975 }
10976
10977 #[cfg(feature = "kv")]
10978 #[test]
10979 fn kv_resource_exhaustion_returns_503_without_latching_readiness() {
10980 let (_dir, runtime) = kv_test_runtime();
10981
10982 let error = runtime.map_kv_read_error(rhiza_kv::Error::ResourceExhausted(
10983 "scan result is too large".into(),
10984 ));
10985 let response = node_error_response(error);
10986
10987 assert_eq!(
10988 response.status(),
10989 axum::http::StatusCode::SERVICE_UNAVAILABLE
10990 );
10991 assert!(runtime.is_ready());
10992 assert!(!runtime.is_fatal());
10993 }
10994
10995 #[cfg(feature = "graph")]
10996 #[test]
10997 fn graph_batch_coalesces_exact_retry_and_isolates_conflicting_duplicate() {
10998 let (_dir, runtime) = graph_test_runtime();
10999 let canonical =
11000 GraphCommandV1::put_document("same", "document", GraphValueV1::String("first".into()))
11001 .unwrap();
11002 let conflict = GraphCommandV1::put_document(
11003 "same",
11004 "document",
11005 GraphValueV1::String("conflict".into()),
11006 )
11007 .unwrap();
11008 let unrelated =
11009 GraphCommandV1::put_document("other", "other", GraphValueV1::U64(2)).unwrap();
11010 let results = runtime
11011 .mutate_graph_batch(vec![canonical.clone(), canonical, conflict, unrelated])
11012 .unwrap();
11013
11014 let canonical = results[0].as_ref().unwrap().applied_index();
11015 assert_eq!(results[1].as_ref().unwrap().applied_index(), canonical);
11016 assert!(matches!(
11017 results[2],
11018 Err(super::NodeError::InvalidRequest(_))
11019 ));
11020 assert_eq!(results[3].as_ref().unwrap().applied_index(), canonical);
11021 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11022 assert!(runtime.is_ready());
11023 }
11024
11025 #[cfg(feature = "kv")]
11026 #[test]
11027 fn kv_batch_coalesces_exact_retry_and_isolates_conflicting_duplicate() {
11028 let (_dir, runtime) = kv_test_runtime();
11029 let canonical = KvCommandV1::put("same", b"key".to_vec(), b"first".to_vec()).unwrap();
11030 let conflict = KvCommandV1::put("same", b"key".to_vec(), b"conflict".to_vec()).unwrap();
11031 let unrelated = KvCommandV1::put("other", b"other".to_vec(), b"second".to_vec()).unwrap();
11032 let results = runtime
11033 .mutate_kv_batch(vec![canonical.clone(), canonical, conflict, unrelated])
11034 .unwrap();
11035
11036 let canonical = results[0].as_ref().unwrap().applied_index();
11037 assert_eq!(results[1].as_ref().unwrap().applied_index(), canonical);
11038 assert!(matches!(
11039 results[2],
11040 Err(super::NodeError::InvalidRequest(_))
11041 ));
11042 assert_eq!(results[3].as_ref().unwrap().applied_index(), canonical);
11043 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11044 assert!(runtime.is_ready());
11045 }
11046
11047 #[cfg(feature = "kv")]
11048 #[test]
11049 fn kv_group_commit_coalesces_four_waiting_64_member_calls_into_one_qlog() {
11050 let (_dir, runtime) = kv_test_runtime();
11051 let runtime = Arc::new(runtime);
11052 let commit = runtime.lock_commit().unwrap();
11053 let start = Arc::new(Barrier::new(5));
11054 let workers = (0..4)
11055 .map(|call| {
11056 let runtime = Arc::clone(&runtime);
11057 let start = Arc::clone(&start);
11058 std::thread::spawn(move || {
11059 let commands = (0..64)
11060 .map(|member| {
11061 let id = call * 64 + member;
11062 KvCommandV1::put(
11063 format!("kv-group-{id}"),
11064 format!("key-{id}").into_bytes(),
11065 vec![u8::try_from(call).unwrap(); 128],
11066 )
11067 .unwrap()
11068 })
11069 .collect();
11070 start.wait();
11071 runtime.mutate_kv_batch(commands)
11072 })
11073 })
11074 .collect::<Vec<_>>();
11075 start.wait();
11076 runtime
11077 .kv_group_commit
11078 .wait_for_pending_calls(4, Duration::from_secs(5));
11079 drop(commit);
11080
11081 let responses = workers
11082 .into_iter()
11083 .map(|worker| worker.join().unwrap().unwrap())
11084 .collect::<Vec<_>>();
11085 let anchors = responses
11086 .iter()
11087 .flatten()
11088 .map(|result| {
11089 let outcome = result.as_ref().unwrap();
11090 (outcome.applied_index(), outcome.hash())
11091 })
11092 .collect::<std::collections::HashSet<_>>();
11093 assert_eq!(anchors.len(), 1);
11094 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11095 }
11096
11097 #[cfg(feature = "kv")]
11098 #[test]
11099 fn kv_group_commit_rejects_public_257_member_call_before_writing() {
11100 let (_dir, runtime) = kv_test_runtime();
11101 let commands = (0..257)
11102 .map(|id| {
11103 KvCommandV1::put(
11104 format!("kv-over-{id}"),
11105 format!("key-{id}").into_bytes(),
11106 b"value".to_vec(),
11107 )
11108 .unwrap()
11109 })
11110 .collect();
11111
11112 let error = runtime.mutate_kv_batch(commands).unwrap_err();
11113
11114 assert!(matches!(error, NodeError::InvalidRequest(_)));
11115 assert_eq!(runtime.log_store().last_index().unwrap(), None);
11116 assert!(runtime
11117 .kv_group_commit
11118 .state
11119 .lock()
11120 .unwrap()
11121 .pending
11122 .is_empty());
11123 }
11124
11125 #[cfg(feature = "kv")]
11126 #[test]
11127 fn kv_group_commit_lone_call_completes_and_leaves_queue_idle() {
11128 let (_dir, runtime) = kv_test_runtime();
11129
11130 let outcome = runtime
11131 .mutate_kv(KvCommandV1::put("kv-lone", b"key".to_vec(), b"value".to_vec()).unwrap())
11132 .unwrap();
11133
11134 assert_eq!(outcome.applied_index(), 1);
11135 let state = runtime
11136 .kv_group_commit
11137 .state
11138 .lock()
11139 .unwrap_or_else(std::sync::PoisonError::into_inner);
11140 assert!(state.pending.is_empty());
11141 assert_eq!(state.pending_encoded_bytes, 0);
11142 assert!(!state.leader_active);
11143 }
11144
11145 #[cfg(feature = "kv")]
11146 #[test]
11147 fn kv_group_commit_shutdown_wakes_waiters_without_writing() {
11148 let (_dir, runtime) = kv_test_runtime();
11149 let runtime = Arc::new(runtime);
11150 let commit = runtime.lock_commit().unwrap();
11151 let start = Arc::new(Barrier::new(3));
11152 let workers = (0..2)
11153 .map(|id| {
11154 let runtime = Arc::clone(&runtime);
11155 let start = Arc::clone(&start);
11156 std::thread::spawn(move || {
11157 start.wait();
11158 runtime.mutate_kv(
11159 KvCommandV1::put(
11160 format!("kv-shutdown-{id}"),
11161 format!("key-{id}").into_bytes(),
11162 b"value".to_vec(),
11163 )
11164 .unwrap(),
11165 )
11166 })
11167 })
11168 .collect::<Vec<_>>();
11169 start.wait();
11170 runtime
11171 .kv_group_commit
11172 .wait_for_pending_calls(2, Duration::from_secs(5));
11173
11174 runtime.cancel_operations();
11175 drop(commit);
11176
11177 for worker in workers {
11178 assert!(matches!(
11179 worker.join().unwrap(),
11180 Err(NodeError::Unavailable(_))
11181 ));
11182 }
11183 assert_eq!(runtime.log_store().last_index().unwrap(), None);
11184 let state = runtime
11185 .kv_group_commit
11186 .state
11187 .lock()
11188 .unwrap_or_else(std::sync::PoisonError::into_inner);
11189 assert!(state.pending.is_empty());
11190 assert_eq!(state.pending_encoded_bytes, 0);
11191 assert!(!state.leader_active);
11192 }
11193
11194 #[cfg(feature = "kv")]
11195 #[test]
11196 fn kv_group_commit_preserves_cross_call_retry_conflict_and_new_result_offsets() {
11197 let (_dir, runtime) = kv_test_runtime();
11198 let runtime = Arc::new(runtime);
11199 let stored = KvCommandV1::put(
11200 "kv-stored",
11201 b"stored-key".to_vec(),
11202 b"stored-value".to_vec(),
11203 )
11204 .unwrap();
11205 let stored_outcome = runtime.mutate_kv(stored.clone()).unwrap();
11206 let conflict =
11207 KvCommandV1::put("kv-stored", b"stored-key".to_vec(), b"conflict".to_vec()).unwrap();
11208 let commit = runtime.lock_commit().unwrap();
11209 let start = Arc::new(Barrier::new(3));
11210 let retry_worker = {
11211 let runtime = Arc::clone(&runtime);
11212 let start = Arc::clone(&start);
11213 std::thread::spawn(move || {
11214 start.wait();
11215 runtime.mutate_kv_batch(vec![stored, conflict])
11216 })
11217 };
11218 let new_worker = {
11219 let runtime = Arc::clone(&runtime);
11220 let start = Arc::clone(&start);
11221 std::thread::spawn(move || {
11222 start.wait();
11223 runtime.mutate_kv_batch(vec![
11224 KvCommandV1::put("kv-new-1", b"new-1".to_vec(), b"one".to_vec()).unwrap(),
11225 KvCommandV1::put("kv-new-2", b"new-2".to_vec(), b"two".to_vec()).unwrap(),
11226 ])
11227 })
11228 };
11229 start.wait();
11230 runtime
11231 .kv_group_commit
11232 .wait_for_pending_calls(2, Duration::from_secs(5));
11233 drop(commit);
11234
11235 let retry_results = retry_worker.join().unwrap().unwrap();
11236 let new_results = new_worker.join().unwrap().unwrap();
11237
11238 assert_eq!(
11239 retry_results[0].as_ref().unwrap().applied_index(),
11240 stored_outcome.applied_index()
11241 );
11242 assert!(matches!(
11243 retry_results[1],
11244 Err(NodeError::InvalidRequest(_))
11245 ));
11246 let new_anchors = new_results
11247 .iter()
11248 .map(|result| {
11249 let outcome = result.as_ref().unwrap();
11250 (outcome.applied_index(), outcome.hash())
11251 })
11252 .collect::<std::collections::HashSet<_>>();
11253 assert_eq!(new_anchors.len(), 1);
11254 assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11255 }
11256
11257 #[cfg(feature = "kv")]
11258 #[test]
11259 fn kv_group_commit_releases_pending_byte_budget_after_drain_and_failure() {
11260 let queue = super::KvGroupCommitQueue::new();
11261 let cancelled = AtomicBool::new(false);
11262 let member = |id: usize, bytes: usize| super::RuntimeBatchMember {
11263 #[cfg(feature = "sql")]
11264 request_id: format!("kv-byte-{id}"),
11265 payload: vec![u8::try_from(id).unwrap_or_default(); bytes],
11266 operation: super::QueuedOperation::Kv(
11267 KvCommandV1::put(
11268 format!("kv-byte-{id}"),
11269 format!("key-{id}").into_bytes(),
11270 b"value".to_vec(),
11271 )
11272 .unwrap(),
11273 ),
11274 };
11275 for id in 0..63 {
11276 queue
11277 .enqueue(vec![member(id, MAX_COMMAND_BYTES)], &cancelled)
11278 .unwrap();
11279 }
11280
11281 let overflow = match queue.enqueue(vec![member(63, MAX_COMMAND_BYTES * 2)], &cancelled) {
11282 Ok(_) => panic!("pending KV byte budget must reject oversized aggregate work"),
11283 Err(error) => error,
11284 };
11285 assert!(matches!(overflow, NodeError::ResourceExhausted(_)));
11286
11287 let drained = queue.drain_next_group().unwrap();
11288 assert_eq!(drained.len(), 63);
11289 let released = queue
11290 .enqueue(vec![member(64, MAX_COMMAND_BYTES)], &cancelled)
11291 .unwrap()
11292 .0;
11293 queue.fail_pending(NodeError::Unavailable("test failure".into()));
11294 assert!(matches!(
11295 released.wait(&cancelled),
11296 Err(NodeError::Unavailable(_))
11297 ));
11298 let state = queue
11299 .state
11300 .lock()
11301 .unwrap_or_else(std::sync::PoisonError::into_inner);
11302 assert!(state.pending.is_empty());
11303 assert_eq!(state.pending_encoded_bytes, 0);
11304 assert!(!state.leader_active);
11305 }
11306
11307 #[cfg(feature = "kv")]
11308 #[test]
11309 fn kv_group_commit_window_restarts_after_staggered_enqueue() {
11310 let queue = Arc::new(super::KvGroupCommitQueue::new());
11311 let cancelled = AtomicBool::new(false);
11312 let member = |id: usize| super::RuntimeBatchMember {
11313 #[cfg(feature = "sql")]
11314 request_id: format!("kv-debounce-{id}"),
11315 payload: vec![u8::try_from(id).unwrap_or_default()],
11316 operation: super::QueuedOperation::Kv(
11317 KvCommandV1::put(
11318 format!("kv-debounce-{id}"),
11319 format!("key-{id}").into_bytes(),
11320 b"value".to_vec(),
11321 )
11322 .unwrap(),
11323 ),
11324 };
11325 queue.enqueue(vec![member(1)], &cancelled).unwrap();
11326 let collector = Arc::clone(&queue);
11327 let (finished, receive) = std::sync::mpsc::channel();
11328 let worker = std::thread::spawn(move || {
11329 let collected = collector.collect_until_full_or_timeout(Duration::from_millis(100));
11330 finished.send(collected).unwrap();
11331 });
11332
11333 std::thread::sleep(Duration::from_millis(75));
11334 queue.enqueue(vec![member(2)], &cancelled).unwrap();
11335 std::thread::sleep(Duration::from_millis(75));
11336 queue.enqueue(vec![member(3)], &cancelled).unwrap();
11337 assert!(receive.recv_timeout(Duration::from_millis(50)).is_err());
11338 assert!(receive.recv_timeout(Duration::from_millis(150)).unwrap());
11339 worker.join().unwrap();
11340 assert_eq!(queue.drain_next_group().unwrap().len(), 3);
11341 }
11342
11343 #[cfg(feature = "kv")]
11344 #[test]
11345 fn kv_group_commit_returns_committed_group_when_cancelled_after_execution() {
11346 let (_dir, mut runtime) = kv_test_runtime();
11347 runtime.kv_group_commit_after_execute_hook = Some(Arc::new(NodeRuntime::cancel_operations));
11348 let runtime = Arc::new(runtime);
11349 let commit = runtime.lock_commit().unwrap();
11350 let start = Arc::new(Barrier::new(3));
11351 let workers = (0..2)
11352 .map(|call| {
11353 let runtime = Arc::clone(&runtime);
11354 let start = Arc::clone(&start);
11355 std::thread::spawn(move || {
11356 let commands = (0..64)
11357 .map(|member| {
11358 let id = call * 64 + member;
11359 KvCommandV1::put(
11360 format!("kv-cancel-after-{id}"),
11361 format!("key-{id}").into_bytes(),
11362 b"value".to_vec(),
11363 )
11364 .unwrap()
11365 })
11366 .collect();
11367 start.wait();
11368 runtime.mutate_kv_batch(commands)
11369 })
11370 })
11371 .collect::<Vec<_>>();
11372 start.wait();
11373 runtime
11374 .kv_group_commit
11375 .wait_for_pending_calls(2, Duration::from_secs(5));
11376 drop(commit);
11377
11378 let results = workers
11379 .into_iter()
11380 .flat_map(|worker| worker.join().unwrap().unwrap())
11381 .collect::<Vec<_>>();
11382
11383 assert_eq!(results.len(), 128);
11384 let anchors = results
11385 .iter()
11386 .map(|result| {
11387 let outcome = result.as_ref().unwrap();
11388 (outcome.applied_index(), outcome.hash())
11389 })
11390 .collect::<std::collections::HashSet<_>>();
11391 assert_eq!(anchors.len(), 1);
11392 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11393 assert!(runtime.operation_cancelled.load(Ordering::Acquire));
11394 }
11395
11396 #[cfg(feature = "kv")]
11397 #[test]
11398 fn kv_largest_fitting_prefix_is_exact_for_large_grouped_batch() {
11399 let commands = (0..256)
11400 .map(|id| {
11401 KvCommandV1::put(
11402 format!("kv-prefix-{id:04}"),
11403 format!("key-{id:04}").into_bytes(),
11404 vec![b'x'; 4 * 1024],
11405 )
11406 .unwrap()
11407 })
11408 .collect::<Vec<_>>();
11409 assert!(super::encode_replicated_kv_batch(&commands).unwrap().len() > MAX_COMMAND_BYTES);
11410 let expected = (2..commands.len())
11411 .filter(|count| {
11412 super::encode_replicated_kv_batch(&commands[..*count])
11413 .unwrap()
11414 .len()
11415 <= MAX_COMMAND_BYTES
11416 })
11417 .max()
11418 .unwrap();
11419
11420 let (count, payload) = super::largest_fitting_kv_batch_prefix(&commands).unwrap();
11421
11422 assert_eq!(count, expected);
11423 assert!(payload.len() <= MAX_COMMAND_BYTES);
11424 assert!(
11425 super::encode_replicated_kv_batch(&commands[..count + 1])
11426 .unwrap()
11427 .len()
11428 > MAX_COMMAND_BYTES
11429 );
11430 }
11431
11432 #[cfg(feature = "kv")]
11433 #[test]
11434 fn kv_group_commit_large_batch_uses_largest_fitting_fifo_sub_batches() {
11435 let (_dir, runtime) = kv_test_runtime();
11436 let runtime = Arc::new(runtime);
11437 let calls = (0..4)
11438 .map(|call| {
11439 (0..64)
11440 .map(|member| {
11441 let id = call * 64 + member;
11442 KvCommandV1::put(
11443 format!("kv-large-{id:04}"),
11444 format!("key-{id:04}").into_bytes(),
11445 vec![b'x'; 4 * 1024],
11446 )
11447 .unwrap()
11448 })
11449 .collect::<Vec<_>>()
11450 })
11451 .collect::<Vec<_>>();
11452 let flattened = calls.iter().flatten().cloned().collect::<Vec<_>>();
11453 let (largest_prefix, _) = super::largest_fitting_kv_batch_prefix(&flattened).unwrap();
11454 let expected_entries = flattened.len().div_ceil(largest_prefix);
11455 let commit = runtime.lock_commit().unwrap();
11456 let start = Arc::new(Barrier::new(5));
11457 let workers = calls
11458 .into_iter()
11459 .map(|commands| {
11460 let runtime = Arc::clone(&runtime);
11461 let start = Arc::clone(&start);
11462 std::thread::spawn(move || {
11463 start.wait();
11464 runtime.mutate_kv_batch(commands)
11465 })
11466 })
11467 .collect::<Vec<_>>();
11468 start.wait();
11469 runtime
11470 .kv_group_commit
11471 .wait_for_pending_calls(4, Duration::from_secs(5));
11472 drop(commit);
11473
11474 let mut counts = std::collections::BTreeMap::new();
11475 for result in workers
11476 .into_iter()
11477 .flat_map(|worker| worker.join().unwrap().unwrap())
11478 {
11479 *counts
11480 .entry(result.unwrap().applied_index())
11481 .or_insert(0_usize) += 1;
11482 }
11483
11484 assert_eq!(counts.len(), expected_entries);
11485 let counts = counts.into_values().collect::<Vec<_>>();
11486 assert!(counts[..counts.len() - 1]
11487 .iter()
11488 .all(|count| *count == largest_prefix));
11489 assert_eq!(counts.iter().sum::<usize>(), 256);
11490 assert_eq!(
11491 runtime.log_store().last_index().unwrap(),
11492 Some(u64::try_from(expected_entries).unwrap())
11493 );
11494 for index in 1..=u64::try_from(expected_entries).unwrap() {
11495 assert!(runtime
11496 .log_store()
11497 .read(index)
11498 .unwrap()
11499 .is_some_and(|entry| entry.payload.len() <= MAX_COMMAND_BYTES));
11500 }
11501 }
11502
11503 #[cfg(feature = "kv")]
11504 #[test]
11505 fn kv_group_commit_leader_panic_wakes_active_and_pending_calls_with_same_fatal() {
11506 let (_dir, mut runtime) = kv_test_runtime();
11507 runtime.kv_group_commit_before_execute_hook =
11508 Some(Arc::new(|| panic!("injected KV group leader panic")));
11509 let runtime = Arc::new(runtime);
11510 let commit = runtime.lock_commit().unwrap();
11511 let mut workers = Vec::new();
11512 for call in 0..17 {
11513 let worker_runtime = Arc::clone(&runtime);
11514 workers.push(std::thread::spawn(move || {
11515 worker_runtime.mutate_kv_batch(
11516 (0..64)
11517 .map(|member| {
11518 let id = call * 64 + member;
11519 KvCommandV1::put(
11520 format!("kv-panic-{id}"),
11521 format!("key-{id}").into_bytes(),
11522 b"value".to_vec(),
11523 )
11524 .unwrap()
11525 })
11526 .collect(),
11527 )
11528 }));
11529 runtime
11530 .kv_group_commit
11531 .wait_for_pending_calls(call + 1, Duration::from_secs(5));
11532 }
11533 drop(commit);
11534
11535 let errors = workers
11536 .into_iter()
11537 .map(|worker| worker.join().unwrap().unwrap_err())
11538 .collect::<Vec<_>>();
11539 assert!(errors
11540 .iter()
11541 .all(|error| matches!(error, NodeError::Fatal(_))));
11542 assert_eq!(errors[0].to_string(), errors[1].to_string());
11543 assert!(runtime.is_fatal());
11544 assert_eq!(runtime.log_store().last_index().unwrap(), None);
11545 }
11546
11547 #[cfg(feature = "kv")]
11548 #[test]
11549 fn kv_group_commit_reopens_all_four_grouped_calls_at_shared_durable_anchor() {
11550 let (dir, runtime) = kv_test_runtime();
11551 let runtime = Arc::new(runtime);
11552 let commit = runtime.lock_commit().unwrap();
11553 let start = Arc::new(Barrier::new(5));
11554 let workers = (0..4)
11555 .map(|call| {
11556 let runtime = Arc::clone(&runtime);
11557 let start = Arc::clone(&start);
11558 std::thread::spawn(move || {
11559 let commands = (0..64)
11560 .map(|member| {
11561 let id = call * 64 + member;
11562 KvCommandV1::put(
11563 format!("kv-reopen-{id}"),
11564 format!("key-{id:04}").into_bytes(),
11565 vec![u8::try_from(call).unwrap(); 128],
11566 )
11567 .unwrap()
11568 })
11569 .collect();
11570 start.wait();
11571 runtime.mutate_kv_batch(commands)
11572 })
11573 })
11574 .collect::<Vec<_>>();
11575 start.wait();
11576 runtime
11577 .kv_group_commit
11578 .wait_for_pending_calls(4, Duration::from_secs(5));
11579 drop(commit);
11580
11581 let results = workers
11582 .into_iter()
11583 .flat_map(|worker| worker.join().unwrap().unwrap())
11584 .collect::<Vec<_>>();
11585 let anchors = results
11586 .iter()
11587 .map(|result| {
11588 let outcome = result.as_ref().unwrap();
11589 (outcome.applied_index(), outcome.hash())
11590 })
11591 .collect::<std::collections::HashSet<_>>();
11592 let [(applied_index, applied_hash)] = anchors.into_iter().collect::<Vec<_>>()[..] else {
11593 panic!("four grouped calls must share one durable anchor");
11594 };
11595 assert_eq!(
11596 runtime.log_store().last_index().unwrap(),
11597 Some(applied_index)
11598 );
11599 let config = runtime.config().clone();
11600 drop(runtime);
11601
11602 let consensus = Arc::new(
11603 ThreeNodeConsensus::from_recovered_tip(
11604 config.cluster_id().to_owned(),
11605 config.node_id().to_owned(),
11606 config.epoch(),
11607 config.config_id(),
11608 [
11609 dir.path().join("recorders/n1"),
11610 dir.path().join("recorders/n2"),
11611 dir.path().join("recorders/n3"),
11612 ],
11613 applied_index + 1,
11614 applied_hash,
11615 )
11616 .unwrap(),
11617 );
11618 let reopened = NodeRuntime::open(config, consensus, &[]).unwrap();
11619
11620 assert_eq!(reopened.applied_index().unwrap(), applied_index);
11621 assert_eq!(reopened.applied_hash().unwrap(), applied_hash);
11622 assert_eq!(reopened.log_store().last_index().unwrap(), Some(1));
11623 for id in 0..256 {
11624 let response = reopened
11625 .get_kv(format!("key-{id:04}").as_bytes(), ReadConsistency::Local)
11626 .unwrap();
11627 assert_eq!(
11628 response.value,
11629 Some(vec![u8::try_from(id / 64).unwrap(); 128])
11630 );
11631 assert_eq!(response.applied_index, applied_index);
11632 assert_eq!(response.hash, applied_hash);
11633 }
11634 }
11635
11636 #[test]
11637 fn sql_batch_preflight_rejects_entire_vector_without_growing_log() {
11638 let (_dir, runtime) = sql_test_runtime();
11639 let valid = SqlCommand {
11640 request_id: "valid".into(),
11641 statements: vec![SqlStatement {
11642 sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY)".into(),
11643 parameters: vec![],
11644 }],
11645 };
11646 let invalid = SqlCommand {
11647 request_id: String::new(),
11648 statements: valid.statements.clone(),
11649 };
11650
11651 let error = runtime.execute_sql_batch(vec![valid, invalid]).unwrap_err();
11652
11653 assert!(matches!(error, NodeError::InvalidRequest(_)));
11654 assert_eq!(runtime.log_store().last_index().unwrap(), None);
11655 }
11656
11657 #[test]
11658 fn sql_batch_rejects_aggregate_encoded_input_over_command_cap_before_io() {
11659 let (_dir, runtime) = sql_test_runtime();
11660 let command = |request_id: &str, fill: char| SqlCommand {
11661 request_id: request_id.into(),
11662 statements: vec![SqlStatement {
11663 sql: "SELECT ?1".into(),
11664 parameters: vec![SqlValue::Text(
11665 std::iter::repeat_n(fill, MAX_COMMAND_BYTES / 2).collect(),
11666 )],
11667 }],
11668 };
11669
11670 let error = runtime
11671 .execute_sql_batch(vec![
11672 command("aggregate-a", 'a'),
11673 command("aggregate-b", 'b'),
11674 ])
11675 .unwrap_err();
11676
11677 assert!(matches!(error, NodeError::ResourceExhausted(_)));
11678 assert_eq!(runtime.log_store().last_index().unwrap(), None);
11679 assert!(runtime
11680 .sql_group_commit
11681 .state
11682 .lock()
11683 .unwrap()
11684 .pending
11685 .is_empty());
11686 }
11687
11688 #[test]
11689 fn sql_write_profiling_records_nothing_when_observer_is_not_installed() {
11690 let profiler = SqlWriteProfiler::new(8);
11691 let (_dir, runtime) = sql_test_runtime();
11692
11693 runtime
11694 .execute_sql(SqlCommand {
11695 request_id: "schema".into(),
11696 statements: vec![SqlStatement {
11697 sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11698 parameters: vec![],
11699 }],
11700 })
11701 .unwrap();
11702
11703 assert!(runtime.config().sql_write_profiler().is_none());
11704 assert!(profiler.snapshot().samples.is_empty());
11705 }
11706
11707 #[test]
11708 fn sql_write_profiling_records_one_consistent_sample_for_one_physical_batch() {
11709 let profiler = SqlWriteProfiler::new(8);
11710 let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11711 runtime
11712 .execute_sql(SqlCommand {
11713 request_id: "schema".into(),
11714 statements: vec![SqlStatement {
11715 sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11716 parameters: vec![],
11717 }],
11718 })
11719 .unwrap();
11720 profiler.drain();
11721
11722 let commands = (1..=3)
11723 .map(|id| SqlCommand {
11724 request_id: format!("insert-{id}"),
11725 statements: vec![SqlStatement {
11726 sql: "INSERT INTO profiled_items(id) VALUES (?1)".into(),
11727 parameters: vec![SqlValue::Integer(id)],
11728 }],
11729 })
11730 .collect();
11731 let responses = runtime.execute_sql_batch(commands).unwrap();
11732
11733 let snapshot = profiler.snapshot();
11734 assert_eq!(snapshot.dropped_samples, 0);
11735 let [sample] = snapshot.samples.as_slice() else {
11736 panic!("one physical SQL batch must emit one sample: {snapshot:?}");
11737 };
11738 assert_eq!(sample.batch_member_count, 3);
11739 assert_eq!(
11740 sample.total_service_us,
11741 sample
11742 .commit_lock_wait_us
11743 .saturating_add(sample.precheck_classification_us)
11744 .saturating_add(sample.qwal_prepare_us)
11745 .saturating_add(sample.consensus_propose_us)
11746 .saturating_add(sample.local_qlog_mirror_append_us)
11747 .saturating_add(sample.sql_materializer_apply_us)
11748 .saturating_add(sample.response_other_total_us)
11749 );
11750 let (applied_index, applied_hash) = runtime.ensure_materialized_tip().unwrap();
11751 assert!(responses.iter().all(|response| {
11752 response.as_ref().is_ok_and(|response| {
11753 response.applied_index == applied_index && response.hash == applied_hash
11754 })
11755 }));
11756 }
11757
11758 #[test]
11759 fn sql_write_profiling_does_not_fabricate_sample_for_failed_batch() {
11760 let profiler = SqlWriteProfiler::new(8);
11761 let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11762 runtime
11763 .execute_sql(SqlCommand {
11764 request_id: "schema".into(),
11765 statements: vec![SqlStatement {
11766 sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11767 parameters: vec![],
11768 }],
11769 })
11770 .unwrap();
11771 runtime
11772 .execute_sql(SqlCommand {
11773 request_id: "first".into(),
11774 statements: vec![SqlStatement {
11775 sql: "INSERT INTO profiled_items(id) VALUES (1)".into(),
11776 parameters: vec![],
11777 }],
11778 })
11779 .unwrap();
11780 profiler.drain();
11781 let last_index = runtime.log_store().last_index().unwrap();
11782
11783 let error = runtime
11784 .execute_sql(SqlCommand {
11785 request_id: "duplicate".into(),
11786 statements: vec![SqlStatement {
11787 sql: "INSERT INTO profiled_items(id) VALUES (1)".into(),
11788 parameters: vec![],
11789 }],
11790 })
11791 .unwrap_err();
11792
11793 assert!(matches!(error, NodeError::InvalidSqlStatement { .. }));
11794 assert!(profiler.snapshot().samples.is_empty());
11795 assert_eq!(runtime.log_store().last_index().unwrap(), last_index);
11796 }
11797
11798 #[test]
11799 fn sql_group_commit_coalesces_four_waiting_typed_calls_into_one_1024_receipt_qwal() {
11800 let profiler = SqlWriteProfiler::new(8);
11801 let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11802 let runtime = Arc::new(runtime);
11803 runtime
11804 .execute_sql(SqlCommand {
11805 request_id: "group-schema".into(),
11806 statements: vec![SqlStatement {
11807 sql: "CREATE TABLE grouped_items(id INTEGER PRIMARY KEY)".into(),
11808 parameters: vec![],
11809 }],
11810 })
11811 .unwrap();
11812 profiler.drain();
11813
11814 let commit = runtime.lock_commit().unwrap();
11815 let start = Arc::new(Barrier::new(5));
11816 let workers = (0..4)
11817 .map(|call| {
11818 let runtime = Arc::clone(&runtime);
11819 let start = Arc::clone(&start);
11820 std::thread::spawn(move || {
11821 let commands = (0..256)
11822 .map(|offset| {
11823 let id = call * 256 + offset;
11824 SqlCommand {
11825 request_id: format!("group-{id}"),
11826 statements: vec![SqlStatement {
11827 sql: "INSERT INTO grouped_items(id) VALUES (?1)".into(),
11828 parameters: vec![SqlValue::Integer(id)],
11829 }],
11830 }
11831 })
11832 .collect();
11833 start.wait();
11834 runtime.execute_sql_batch(commands).unwrap()
11835 })
11836 })
11837 .collect::<Vec<_>>();
11838 start.wait();
11839 runtime
11840 .sql_group_commit
11841 .wait_for_pending_calls(4, Duration::from_secs(5));
11842 drop(commit);
11843
11844 let results = workers
11845 .into_iter()
11846 .flat_map(|worker| worker.join().unwrap())
11847 .collect::<Vec<_>>();
11848 assert_eq!(results.len(), 1024);
11849 let first = results[0].as_ref().unwrap();
11850 assert!(results.iter().all(|result| {
11851 result.as_ref().is_ok_and(|result| {
11852 result.applied_index == first.applied_index && result.hash == first.hash
11853 })
11854 }));
11855 let entry = runtime
11856 .log_store()
11857 .read(first.applied_index)
11858 .unwrap()
11859 .unwrap();
11860 assert_eq!(
11861 rhiza_sql::decode_qwal_v3(&entry.payload)
11862 .unwrap()
11863 .receipts
11864 .len(),
11865 1024
11866 );
11867 assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11868 let snapshot = profiler.snapshot();
11869 let [sample] = snapshot.samples.as_slice() else {
11870 panic!("one grouped physical commit must emit one sample: {snapshot:?}");
11871 };
11872 assert_eq!(sample.batch_member_count, 1024);
11873 }
11874
11875 #[test]
11876 fn sql_group_commit_keeps_fifth_whole_call_for_the_next_physical_group() {
11877 let (_dir, runtime) = sql_test_runtime();
11878 let runtime = Arc::new(runtime);
11879 runtime
11880 .execute_sql(SqlCommand {
11881 request_id: "next-group-schema".into(),
11882 statements: vec![SqlStatement {
11883 sql: "CREATE TABLE next_group_items(id INTEGER PRIMARY KEY)".into(),
11884 parameters: vec![],
11885 }],
11886 })
11887 .unwrap();
11888
11889 let commit = runtime.lock_commit().unwrap();
11890 let mut workers = Vec::new();
11891 for call in 0..5 {
11892 let worker_runtime = Arc::clone(&runtime);
11893 workers.push(std::thread::spawn(move || {
11894 worker_runtime
11895 .execute_sql_batch(
11896 (0..256)
11897 .map(|offset| {
11898 let id = call * 256 + offset;
11899 SqlCommand {
11900 request_id: format!("next-group-{id}"),
11901 statements: vec![SqlStatement {
11902 sql: "INSERT INTO next_group_items(id) VALUES (?1)".into(),
11903 parameters: vec![SqlValue::Integer(id)],
11904 }],
11905 }
11906 })
11907 .collect(),
11908 )
11909 .unwrap()
11910 }));
11911 runtime
11912 .sql_group_commit
11913 .wait_for_pending_calls(call as usize + 1, Duration::from_secs(5));
11914 }
11915 drop(commit);
11916
11917 let calls = workers
11918 .into_iter()
11919 .map(|worker| worker.join().unwrap())
11920 .collect::<Vec<_>>();
11921 for call in &calls[..4] {
11922 assert!(call
11923 .iter()
11924 .all(|result| result.as_ref().unwrap().applied_index == 2));
11925 }
11926 assert!(calls[4]
11927 .iter()
11928 .all(|result| result.as_ref().unwrap().applied_index == 3));
11929 assert_eq!(
11930 rhiza_sql::decode_qwal_v3(&runtime.log_store().read(2).unwrap().unwrap().payload)
11931 .unwrap()
11932 .receipts
11933 .len(),
11934 1024
11935 );
11936 assert_eq!(
11937 rhiza_sql::decode_qwal_v3(&runtime.log_store().read(3).unwrap().unwrap().payload)
11938 .unwrap()
11939 .receipts
11940 .len(),
11941 256
11942 );
11943 }
11944
11945 #[test]
11946 fn sql_group_commit_preserves_fifo_call_offsets_for_retries_conflicts_aliases_and_failures() {
11947 let (_dir, runtime) = sql_test_runtime();
11948 let runtime = Arc::new(runtime);
11949 runtime
11950 .execute_sql(SqlCommand {
11951 request_id: "fifo-schema".into(),
11952 statements: vec![SqlStatement {
11953 sql: "CREATE TABLE fifo_items(id INTEGER PRIMARY KEY, value TEXT UNIQUE)"
11954 .into(),
11955 parameters: vec![],
11956 }],
11957 })
11958 .unwrap();
11959 let stored_command = SqlCommand {
11960 request_id: "fifo-stored".into(),
11961 statements: vec![SqlStatement {
11962 sql: "INSERT INTO fifo_items(id, value) VALUES (1, 'stored')".into(),
11963 parameters: vec![],
11964 }],
11965 };
11966 let stored = runtime.execute_sql(stored_command.clone()).unwrap();
11967 let valid_alias = SqlCommand {
11968 request_id: "fifo-alias".into(),
11969 statements: vec![SqlStatement {
11970 sql: "INSERT INTO fifo_items(id, value) VALUES (2, 'alias')".into(),
11971 parameters: vec![],
11972 }],
11973 };
11974 let conflict = SqlCommand {
11975 request_id: stored_command.request_id.clone(),
11976 statements: vec![SqlStatement {
11977 sql: "INSERT INTO fifo_items(id, value) VALUES (3, 'conflict')".into(),
11978 parameters: vec![],
11979 }],
11980 };
11981 let failed = SqlCommand {
11982 request_id: "fifo-failed".into(),
11983 statements: vec![SqlStatement {
11984 sql: "INSERT INTO fifo_items(id, value) VALUES (4, 'stored')".into(),
11985 parameters: vec![],
11986 }],
11987 };
11988 let valid = SqlCommand {
11989 request_id: "fifo-valid".into(),
11990 statements: vec![SqlStatement {
11991 sql: "INSERT INTO fifo_items(id, value) VALUES (5, 'valid')".into(),
11992 parameters: vec![],
11993 }],
11994 };
11995
11996 let commit = runtime.lock_commit().unwrap();
11997 let first_runtime = Arc::clone(&runtime);
11998 let first = std::thread::spawn(move || {
11999 first_runtime
12000 .execute_sql_batch(vec![
12001 stored_command,
12002 conflict,
12003 valid_alias.clone(),
12004 valid_alias,
12005 ])
12006 .unwrap()
12007 });
12008 runtime
12009 .sql_group_commit
12010 .wait_for_pending_calls(1, Duration::from_secs(5));
12011 let second_runtime = Arc::clone(&runtime);
12012 let second = std::thread::spawn(move || {
12013 second_runtime
12014 .execute_sql_batch(vec![failed, valid])
12015 .unwrap()
12016 });
12017 runtime
12018 .sql_group_commit
12019 .wait_for_pending_calls(2, Duration::from_secs(5));
12020 drop(commit);
12021
12022 let first = first.join().unwrap();
12023 let second = second.join().unwrap();
12024 assert_eq!(
12025 first[0].as_ref().unwrap().applied_index,
12026 stored.applied_index
12027 );
12028 assert!(matches!(first[1], Err(NodeError::RequestConflict(_))));
12029 assert_eq!(first[2], first[3]);
12030 assert_eq!(first[2].as_ref().unwrap().applied_index, 3);
12031 assert!(matches!(
12032 second[0],
12033 Err(NodeError::InvalidSqlStatement { .. })
12034 ));
12035 assert_eq!(second[1].as_ref().unwrap().applied_index, 3);
12036 }
12037
12038 #[test]
12039 fn sql_group_commit_rejects_overload_before_enqueue_without_orphaning_the_leader() {
12040 let (_dir, runtime) = sql_test_runtime_configured(None, Some(1));
12041 let runtime = Arc::new(runtime);
12042 runtime
12043 .execute_sql(SqlCommand {
12044 request_id: "overload-schema".into(),
12045 statements: vec![SqlStatement {
12046 sql: "CREATE TABLE overload_items(id INTEGER PRIMARY KEY)".into(),
12047 parameters: vec![],
12048 }],
12049 })
12050 .unwrap();
12051 let command = |request_id: &str, id| SqlCommand {
12052 request_id: request_id.into(),
12053 statements: vec![SqlStatement {
12054 sql: "INSERT INTO overload_items(id) VALUES (?1)".into(),
12055 parameters: vec![SqlValue::Integer(id)],
12056 }],
12057 };
12058
12059 let commit = runtime.lock_commit().unwrap();
12060 let leader_runtime = Arc::clone(&runtime);
12061 let leader = std::thread::spawn(move || {
12062 leader_runtime.execute_sql_batch(vec![command("overload-first", 1)])
12063 });
12064 runtime
12065 .sql_group_commit
12066 .wait_for_pending_calls(1, Duration::from_secs(5));
12067 let overload = runtime
12068 .execute_sql_batch(vec![command("overload-second", 2)])
12069 .unwrap_err();
12070 assert!(matches!(overload, NodeError::ResourceExhausted(_)));
12071 drop(commit);
12072 assert!(leader.join().unwrap().unwrap()[0].is_ok());
12073 assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
12074 }
12075
12076 #[test]
12077 fn sql_group_commit_bounds_pending_bytes_and_releases_reservations() {
12078 let queue = super::SqlGroupCommitQueue::new(super::MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY);
12079 let cancelled = AtomicBool::new(false);
12080 let member = |id: usize| super::RuntimeBatchMember {
12081 request_id: format!("queued-{id}"),
12082 payload: vec![u8::try_from(id).unwrap_or_default(); MAX_COMMAND_BYTES],
12083 operation: super::QueuedOperation::Sql(SqlCommand {
12084 request_id: format!("queued-{id}"),
12085 statements: vec![SqlStatement {
12086 sql: "SELECT 1".into(),
12087 parameters: vec![],
12088 }],
12089 }),
12090 };
12091 let mut queued = Vec::new();
12092 for id in 0..super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY {
12093 queued.push(queue.enqueue(vec![member(id)], &cancelled).unwrap().0);
12094 }
12095
12096 let overflow = match queue.enqueue(
12097 vec![member(super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY)],
12098 &cancelled,
12099 ) {
12100 Ok(_) => panic!("pending byte budget must reject one more full command"),
12101 Err(error) => error,
12102 };
12103 assert!(matches!(overflow, NodeError::ResourceExhausted(_)));
12104
12105 let drained = queue.drain_next_group().unwrap();
12106 assert_eq!(drained.len(), 4);
12107 let released = queue
12108 .enqueue(
12109 vec![member(super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY + 1)],
12110 &cancelled,
12111 )
12112 .unwrap()
12113 .0;
12114
12115 queue.fail_pending(NodeError::Unavailable("test failure".into()));
12116 for job in queued.into_iter().skip(drained.len()) {
12117 assert!(matches!(
12118 job.wait(&cancelled),
12119 Err(NodeError::Unavailable(_))
12120 ));
12121 }
12122 assert!(matches!(
12123 released.wait(&cancelled),
12124 Err(NodeError::Unavailable(_))
12125 ));
12126 let state = queue
12127 .state
12128 .lock()
12129 .unwrap_or_else(std::sync::PoisonError::into_inner);
12130 assert!(state.pending.is_empty());
12131 assert_eq!(state.pending_encoded_bytes, 0);
12132 assert!(!state.leader_active);
12133 }
12134
12135 #[test]
12136 fn sql_group_commit_window_restarts_after_staggered_enqueue() {
12137 let queue = Arc::new(super::SqlGroupCommitQueue::new(
12138 super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY,
12139 ));
12140 let cancelled = AtomicBool::new(false);
12141 let member = |id: usize| super::RuntimeBatchMember {
12142 request_id: format!("sql-debounce-{id}"),
12143 payload: vec![u8::try_from(id).unwrap_or_default()],
12144 operation: super::QueuedOperation::Sql(SqlCommand {
12145 request_id: format!("sql-debounce-{id}"),
12146 statements: vec![SqlStatement {
12147 sql: "SELECT 1".into(),
12148 parameters: vec![],
12149 }],
12150 }),
12151 };
12152 queue.enqueue(vec![member(1)], &cancelled).unwrap();
12153 let collector = Arc::clone(&queue);
12154 let (finished, receive) = std::sync::mpsc::channel();
12155 let worker = std::thread::spawn(move || {
12156 let collected = collector.collect_until_full_or_timeout(Duration::from_millis(100));
12157 finished.send(collected).unwrap();
12158 });
12159
12160 std::thread::sleep(Duration::from_millis(75));
12161 queue.enqueue(vec![member(2)], &cancelled).unwrap();
12162 std::thread::sleep(Duration::from_millis(75));
12163 queue.enqueue(vec![member(3)], &cancelled).unwrap();
12164 assert!(receive.recv_timeout(Duration::from_millis(50)).is_err());
12165 assert!(receive.recv_timeout(Duration::from_millis(150)).unwrap());
12166 worker.join().unwrap();
12167 assert_eq!(queue.drain_next_group().unwrap().len(), 3);
12168 }
12169
12170 #[test]
12171 fn sql_group_commit_leader_panic_wakes_every_queued_call_with_the_same_fatal_error() {
12172 let (_dir, mut runtime) = sql_test_runtime();
12173 runtime
12174 .execute_sql(SqlCommand {
12175 request_id: "panic-schema".into(),
12176 statements: vec![SqlStatement {
12177 sql: "CREATE TABLE panic_items(id INTEGER PRIMARY KEY)".into(),
12178 parameters: vec![],
12179 }],
12180 })
12181 .unwrap();
12182 runtime.sql_group_commit_before_execute_hook =
12183 Some(Arc::new(|| panic!("injected SQL group leader panic")));
12184 let runtime = Arc::new(runtime);
12185 let commit = runtime.lock_commit().unwrap();
12186 let mut workers = Vec::new();
12187 for id in 1..=2 {
12188 let worker_runtime = Arc::clone(&runtime);
12189 workers.push(std::thread::spawn(move || {
12190 worker_runtime.execute_sql_batch(vec![SqlCommand {
12191 request_id: format!("panic-{id}"),
12192 statements: vec![SqlStatement {
12193 sql: "INSERT INTO panic_items(id) VALUES (?1)".into(),
12194 parameters: vec![SqlValue::Integer(id)],
12195 }],
12196 }])
12197 }));
12198 runtime
12199 .sql_group_commit
12200 .wait_for_pending_calls(id as usize, Duration::from_secs(5));
12201 }
12202 drop(commit);
12203
12204 let errors = workers
12205 .into_iter()
12206 .map(|worker| worker.join().unwrap().unwrap_err())
12207 .collect::<Vec<_>>();
12208 assert!(errors
12209 .iter()
12210 .all(|error| matches!(error, NodeError::Fatal(_))));
12211 assert_eq!(errors[0].to_string(), errors[1].to_string());
12212 assert!(runtime.is_fatal());
12213 }
12214
12215 #[test]
12216 fn sql_group_commit_shutdown_wakes_queued_calls_with_the_same_unavailable_error() {
12217 let (_dir, runtime) = sql_test_runtime();
12218 let runtime = Arc::new(runtime);
12219 runtime
12220 .execute_sql(SqlCommand {
12221 request_id: "shutdown-schema".into(),
12222 statements: vec![SqlStatement {
12223 sql: "CREATE TABLE shutdown_items(id INTEGER PRIMARY KEY)".into(),
12224 parameters: vec![],
12225 }],
12226 })
12227 .unwrap();
12228 let command = |id| SqlCommand {
12229 request_id: format!("shutdown-{id}"),
12230 statements: vec![SqlStatement {
12231 sql: "INSERT INTO shutdown_items(id) VALUES (?1)".into(),
12232 parameters: vec![SqlValue::Integer(id)],
12233 }],
12234 };
12235
12236 let commit = runtime.lock_commit().unwrap();
12237 let leader_runtime = Arc::clone(&runtime);
12238 let leader = std::thread::spawn(move || leader_runtime.execute_sql_batch(vec![command(1)]));
12239 runtime
12240 .sql_group_commit
12241 .wait_for_pending_calls(1, Duration::from_secs(5));
12242 let follower_runtime = Arc::clone(&runtime);
12243 let follower =
12244 std::thread::spawn(move || follower_runtime.execute_sql_batch(vec![command(2)]));
12245 runtime
12246 .sql_group_commit
12247 .wait_for_pending_calls(2, Duration::from_secs(5));
12248
12249 runtime.cancel_operations();
12250 let follower_error = follower.join().unwrap().unwrap_err();
12251 drop(commit);
12252 let leader_error = leader.join().unwrap().unwrap_err();
12253
12254 assert!(matches!(leader_error, NodeError::Unavailable(_)));
12255 assert_eq!(leader_error.to_string(), follower_error.to_string());
12256 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
12257 }
12258
12259 #[test]
12260 fn sql_group_commit_reprepares_combined_calls_after_a_foreign_slot_winner() {
12261 let (_dir, runtime) = sql_test_runtime();
12262 let runtime = Arc::new(runtime);
12263 let schema = runtime
12264 .execute_sql(SqlCommand {
12265 request_id: "group-winner-schema".into(),
12266 statements: vec![SqlStatement {
12267 sql: "CREATE TABLE group_winner(id INTEGER PRIMARY KEY)".into(),
12268 parameters: vec![],
12269 }],
12270 })
12271 .unwrap();
12272 let winner = runtime
12273 .consensus()
12274 .propose_at(
12275 2,
12276 schema.hash,
12277 Command::new(CommandKind::ReadBarrier, Vec::new()),
12278 )
12279 .unwrap();
12280
12281 let commit = runtime.lock_commit().unwrap();
12282 let mut workers = Vec::new();
12283 for id in 1..=2 {
12284 let worker_runtime = Arc::clone(&runtime);
12285 workers.push(std::thread::spawn(move || {
12286 worker_runtime
12287 .execute_sql_batch(vec![SqlCommand {
12288 request_id: format!("group-winner-{id}"),
12289 statements: vec![SqlStatement {
12290 sql: "INSERT INTO group_winner(id) VALUES (?1)".into(),
12291 parameters: vec![SqlValue::Integer(id)],
12292 }],
12293 }])
12294 .unwrap()
12295 }));
12296 runtime
12297 .sql_group_commit
12298 .wait_for_pending_calls(id as usize, Duration::from_secs(5));
12299 }
12300 drop(commit);
12301
12302 let results = workers
12303 .into_iter()
12304 .flat_map(|worker| worker.join().unwrap())
12305 .collect::<Vec<_>>();
12306 assert_eq!(runtime.log_store().read(2).unwrap(), Some(winner));
12307 assert!(results
12308 .iter()
12309 .all(|result| result.as_ref().unwrap().applied_index == 3));
12310 assert_eq!(
12311 results[0].as_ref().unwrap().hash,
12312 results[1].as_ref().unwrap().hash
12313 );
12314 }
12315
12316 #[test]
12317 fn sql_group_commit_all_failed_calls_return_aligned_without_consensus() {
12318 let (_dir, runtime) = sql_test_runtime();
12319 let runtime = Arc::new(runtime);
12320 runtime
12321 .execute_sql(SqlCommand {
12322 request_id: "all-failed-schema".into(),
12323 statements: vec![SqlStatement {
12324 sql: "CREATE TABLE all_failed(value TEXT UNIQUE)".into(),
12325 parameters: vec![],
12326 }],
12327 })
12328 .unwrap();
12329 runtime
12330 .execute_sql(SqlCommand {
12331 request_id: "all-failed-existing".into(),
12332 statements: vec![SqlStatement {
12333 sql: "INSERT INTO all_failed(value) VALUES ('existing')".into(),
12334 parameters: vec![],
12335 }],
12336 })
12337 .unwrap();
12338
12339 let commit = runtime.lock_commit().unwrap();
12340 let mut workers = Vec::new();
12341 for id in 1..=2 {
12342 let worker_runtime = Arc::clone(&runtime);
12343 workers.push(std::thread::spawn(move || {
12344 worker_runtime
12345 .execute_sql_batch(vec![SqlCommand {
12346 request_id: format!("all-failed-{id}"),
12347 statements: vec![SqlStatement {
12348 sql: "INSERT INTO all_failed(value) VALUES ('existing')".into(),
12349 parameters: vec![],
12350 }],
12351 }])
12352 .unwrap()
12353 }));
12354 runtime
12355 .sql_group_commit
12356 .wait_for_pending_calls(id as usize, Duration::from_secs(5));
12357 }
12358 drop(commit);
12359
12360 for worker in workers {
12361 let results = worker.join().unwrap();
12362 assert_eq!(results.len(), 1);
12363 assert!(matches!(
12364 results[0],
12365 Err(NodeError::InvalidSqlStatement { .. })
12366 ));
12367 }
12368 assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
12369 }
12370
12371 #[test]
12372 fn sql_group_commit_lone_call_completes_after_the_bounded_collection_round() {
12373 let (_dir, runtime) = sql_test_runtime();
12374
12375 let result = runtime
12376 .execute_sql_batch(vec![SqlCommand {
12377 request_id: "lone-call".into(),
12378 statements: vec![SqlStatement {
12379 sql: "CREATE TABLE lone_call(id INTEGER PRIMARY KEY)".into(),
12380 parameters: vec![],
12381 }],
12382 }])
12383 .unwrap();
12384
12385 assert!(result[0].is_ok());
12386 let queue = runtime
12387 .sql_group_commit
12388 .state
12389 .lock()
12390 .unwrap_or_else(std::sync::PoisonError::into_inner);
12391 assert!(queue.pending.is_empty());
12392 assert!(!queue.leader_active);
12393 }
12394
12395 #[test]
12396 fn key_value_write_commits_qwal_instead_of_raw_put_payload() {
12397 let (_dir, runtime) = sql_test_runtime();
12398
12399 let response = runtime.write("key-value-put", "key", "value").unwrap();
12400
12401 let entry = runtime
12402 .log_store()
12403 .read(response.applied_index)
12404 .unwrap()
12405 .unwrap();
12406 assert!(entry.payload.starts_with(QWAL_V3_MAGIC));
12407 assert!(!entry.payload.starts_with(b"put\t"));
12408 assert_eq!(
12409 runtime.read("key", ReadConsistency::Local).unwrap().value,
12410 Some("value".into())
12411 );
12412 }
12413
12414 #[test]
12415 fn sql_batch_preserves_order_commits_one_qwal_effect_and_retries_exactly() {
12416 let (_dir, runtime) = sql_test_runtime();
12417 runtime
12418 .execute_sql(SqlCommand {
12419 request_id: "schema".into(),
12420 statements: vec![SqlStatement {
12421 sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
12422 .into(),
12423 parameters: vec![],
12424 }],
12425 })
12426 .unwrap();
12427 let commands = (1..=3)
12428 .map(|id| SqlCommand {
12429 request_id: format!("insert-{id}"),
12430 statements: vec![SqlStatement {
12431 sql: "INSERT INTO batch_items(id, value) VALUES (?1, ?2)".into(),
12432 parameters: vec![SqlValue::Integer(id), SqlValue::Text(format!("value-{id}"))],
12433 }],
12434 })
12435 .collect::<Vec<_>>();
12436
12437 let first = runtime.execute_sql_batch(commands.clone()).unwrap();
12438 let first_indices = first
12439 .iter()
12440 .map(|result| result.as_ref().unwrap().applied_index)
12441 .collect::<Vec<_>>();
12442 let log_index = runtime.log_store().last_index().unwrap();
12443 let replay = runtime.execute_sql_batch(commands).unwrap();
12444
12445 assert_eq!(first_indices, vec![2, 2, 2]);
12446 assert_eq!(
12447 first
12448 .iter()
12449 .map(|result| result.as_ref().unwrap().hash)
12450 .collect::<std::collections::HashSet<_>>()
12451 .len(),
12452 1
12453 );
12454 for index in 1..=2 {
12455 let entry = runtime.log_store().read(index).unwrap().unwrap();
12456 assert!(entry.payload.starts_with(QWAL_V3_MAGIC));
12457 }
12458 assert_eq!(
12459 replay
12460 .iter()
12461 .map(|result| result.as_ref().unwrap().applied_index)
12462 .collect::<Vec<_>>(),
12463 first_indices
12464 );
12465 assert_eq!(runtime.log_store().last_index().unwrap(), log_index);
12466 }
12467
12468 #[test]
12469 fn sql_effect_over_qlog_limit_is_resource_exhausted() {
12470 let (_dir, runtime) = sql_test_runtime();
12471 runtime
12472 .execute_sql(SqlCommand {
12473 request_id: "schema".into(),
12474 statements: vec![SqlStatement {
12475 sql: "CREATE TABLE large_effect(value BLOB NOT NULL)".into(),
12476 parameters: vec![],
12477 }],
12478 })
12479 .unwrap();
12480
12481 let error = runtime
12482 .execute_sql(SqlCommand {
12483 request_id: "large-effect".into(),
12484 statements: vec![SqlStatement {
12485 sql: "INSERT INTO large_effect(value) VALUES (randomblob(700000))".into(),
12486 parameters: vec![],
12487 }],
12488 })
12489 .unwrap_err();
12490
12491 assert!(matches!(error, NodeError::ResourceExhausted(_)));
12492 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
12493 }
12494
12495 #[test]
12496 fn sql_batch_isolates_request_conflict_from_unrelated_member() {
12497 let (_dir, runtime) = sql_test_runtime();
12498 runtime
12499 .execute_sql(SqlCommand {
12500 request_id: "schema".into(),
12501 statements: vec![SqlStatement {
12502 sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
12503 .into(),
12504 parameters: vec![],
12505 }],
12506 })
12507 .unwrap();
12508 let insert = |request_id: &str, id: i64| SqlCommand {
12509 request_id: request_id.into(),
12510 statements: vec![SqlStatement {
12511 sql: "INSERT INTO batch_items(id, value) VALUES (?1, ?2)".into(),
12512 parameters: vec![SqlValue::Integer(id), SqlValue::Text(format!("value-{id}"))],
12513 }],
12514 };
12515
12516 let results = runtime
12517 .execute_sql_batch(vec![
12518 insert("same", 1),
12519 insert("same", 2),
12520 insert("other", 3),
12521 ])
12522 .unwrap();
12523
12524 assert!(results[0].is_ok());
12525 assert!(matches!(results[1], Err(NodeError::RequestConflict(_))));
12526 let conflict = results[1].as_ref().unwrap_err().classification();
12527 assert_eq!(conflict.code(), "request_conflict");
12528 assert_eq!(conflict.category(), ErrorCategory::Conflict);
12529 assert!(!conflict.retryable());
12530 assert!(results[2].is_ok());
12531 assert_eq!(
12532 results[0].as_ref().unwrap().applied_index,
12533 results[2].as_ref().unwrap().applied_index
12534 );
12535 assert!(runtime.is_ready());
12536 }
12537
12538 #[cfg(feature = "graph")]
12539 #[test]
12540 fn typed_batch_wrong_profile_is_rejected_before_log_attempt() {
12541 let (_dir, runtime) = graph_test_runtime();
12542 let command = SqlCommand {
12543 request_id: "wrong-profile".into(),
12544 statements: vec![SqlStatement {
12545 sql: "CREATE TABLE should_not_exist(id INTEGER PRIMARY KEY)".into(),
12546 parameters: vec![],
12547 }],
12548 };
12549
12550 let error = runtime.execute_sql_batch(vec![command]).unwrap_err();
12551
12552 assert!(matches!(
12553 error,
12554 NodeError::ExecutionProfileMismatch {
12555 expected: ExecutionProfile::Sqlite,
12556 actual: ExecutionProfile::Graph
12557 }
12558 ));
12559 assert_eq!(runtime.log_store().last_index().unwrap(), None);
12560 }
12561
12562 #[cfg(feature = "graph")]
12563 #[test]
12564 fn graph_query_timeout_returns_503_without_latching_readiness() {
12565 let (_dir, runtime) = graph_test_runtime();
12566 let graph = runtime.graph_materializer().unwrap();
12567 let graph_error = graph
12568 .query_read_only(
12569 "UNWIND range(1, 10000) AS x UNWIND range(1, 10000) AS y RETURN sum(x * y) AS total LIMIT 1",
12570 &std::collections::BTreeMap::new(),
12571 1,
12572 1024 * 1024,
12573 1,
12574 )
12575 .unwrap_err();
12576
12577 let response = node_error_response(runtime.map_graph_read_error(graph_error));
12578
12579 assert_eq!(
12580 response.status(),
12581 axum::http::StatusCode::SERVICE_UNAVAILABLE
12582 );
12583 assert!(runtime.is_ready());
12584 assert!(!runtime.is_fatal());
12585 }
12586
12587 #[cfg(feature = "graph")]
12588 #[test]
12589 fn graph_internal_error_returns_500_and_latches_readiness() {
12590 let (_dir, runtime) = graph_test_runtime();
12591
12592 let error =
12593 runtime.map_graph_read_error(rhiza_graph::Error::Ladybug("connection failed".into()));
12594 let response = node_error_response(error);
12595
12596 assert_eq!(
12597 response.status(),
12598 axum::http::StatusCode::INTERNAL_SERVER_ERROR
12599 );
12600 assert!(!runtime.is_ready());
12601 assert!(runtime.is_fatal());
12602 }
12603
12604 fn sql_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12605 sql_test_runtime_configured(None, None)
12606 }
12607
12608 fn sql_test_runtime_with_profiler(
12609 profiler: Option<SqlWriteProfiler>,
12610 ) -> (tempfile::TempDir, NodeRuntime) {
12611 sql_test_runtime_configured(profiler, None)
12612 }
12613
12614 fn sql_test_runtime_configured(
12615 profiler: Option<SqlWriteProfiler>,
12616 queue_capacity: Option<usize>,
12617 ) -> (tempfile::TempDir, NodeRuntime) {
12618 let dir = tempfile::tempdir().unwrap();
12619 let cluster_id = "node-unit-test";
12620 let mut config = NodeConfig::new_embedded(
12621 cluster_id,
12622 "n1",
12623 dir.path().join("node"),
12624 1,
12625 1,
12626 ["n1", "n2", "n3"],
12627 )
12628 .unwrap()
12629 .with_execution_profile(ExecutionProfile::Sqlite)
12630 .unwrap();
12631 if let Some(profiler) = profiler {
12632 config = config.with_sql_write_profiler(profiler);
12633 }
12634 if let Some(queue_capacity) = queue_capacity {
12635 config = config
12636 .with_sql_group_commit_queue_capacity(queue_capacity)
12637 .unwrap();
12638 }
12639 let consensus = Arc::new(
12640 ThreeNodeConsensus::from_recovered_tip(
12641 "rhiza:sql:node-unit-test",
12642 "n1",
12643 1,
12644 1,
12645 [
12646 dir.path().join("recorders/n1"),
12647 dir.path().join("recorders/n2"),
12648 dir.path().join("recorders/n3"),
12649 ],
12650 1,
12651 LogHash::ZERO,
12652 )
12653 .unwrap(),
12654 );
12655 let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12656 (dir, runtime)
12657 }
12658
12659 #[cfg(feature = "graph")]
12660 fn graph_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12661 let dir = tempfile::tempdir().unwrap();
12662 let cluster_id = "node-unit-test";
12663 let config = NodeConfig::new_embedded(
12664 cluster_id,
12665 "n1",
12666 dir.path().join("node"),
12667 1,
12668 1,
12669 ["n1", "n2", "n3"],
12670 )
12671 .unwrap()
12672 .with_execution_profile(ExecutionProfile::Graph)
12673 .unwrap();
12674 let consensus = Arc::new(
12675 ThreeNodeConsensus::from_recovered_tip(
12676 "rhiza:graph:node-unit-test",
12677 "n1",
12678 1,
12679 1,
12680 [
12681 dir.path().join("recorders/n1"),
12682 dir.path().join("recorders/n2"),
12683 dir.path().join("recorders/n3"),
12684 ],
12685 1,
12686 LogHash::ZERO,
12687 )
12688 .unwrap(),
12689 );
12690 let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12691 (dir, runtime)
12692 }
12693
12694 #[cfg(feature = "kv")]
12695 fn kv_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12696 let dir = tempfile::tempdir().unwrap();
12697 let cluster_id = "node-unit-test";
12698 let config = NodeConfig::new_embedded(
12699 cluster_id,
12700 "n1",
12701 dir.path().join("node"),
12702 1,
12703 1,
12704 ["n1", "n2", "n3"],
12705 )
12706 .unwrap()
12707 .with_execution_profile(ExecutionProfile::Kv)
12708 .unwrap();
12709 let consensus = Arc::new(
12710 ThreeNodeConsensus::from_recovered_tip(
12711 "rhiza:kv:node-unit-test",
12712 "n1",
12713 1,
12714 1,
12715 [
12716 dir.path().join("recorders/n1"),
12717 dir.path().join("recorders/n2"),
12718 dir.path().join("recorders/n3"),
12719 ],
12720 1,
12721 LogHash::ZERO,
12722 )
12723 .unwrap(),
12724 );
12725 let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12726 (dir, runtime)
12727 }
12728}
12729
12730#[cfg(feature = "kv")]
12731fn largest_fitting_kv_batch_prefix(commands: &[KvCommandV1]) -> Option<(usize, Vec<u8>)> {
12732 if commands.len() < 3 {
12733 return None;
12734 }
12735 let mut lower = 2_usize;
12736 let mut upper = commands.len() - 1;
12737 let mut largest = None;
12738 while lower <= upper {
12739 let count = lower + (upper - lower) / 2;
12740 let payload = encode_replicated_kv_batch(&commands[..count])
12741 .expect("the validated KV batch prefix remains valid");
12742 if payload.len() <= MAX_COMMAND_BYTES {
12743 largest = Some((count, payload));
12744 lower = count + 1;
12745 } else {
12746 upper = count - 1;
12747 }
12748 }
12749 largest
12750}
12751
12752#[cfg(feature = "graph")]
12753fn validate_typed_batch_len(len: usize) -> Result<(), NodeError> {
12754 if (1..=MAX_WRITE_BATCH_MEMBERS).contains(&len) {
12755 Ok(())
12756 } else {
12757 Err(NodeError::InvalidRequest(format!(
12758 "write batch must contain 1..={MAX_WRITE_BATCH_MEMBERS} commands"
12759 )))
12760 }
12761}
12762
12763#[cfg(feature = "kv")]
12764fn validate_kv_batch_len(len: usize) -> Result<(), NodeError> {
12765 if (1..=MAX_KV_BATCH_MEMBERS).contains(&len) {
12766 Ok(())
12767 } else {
12768 Err(NodeError::InvalidRequest(format!(
12769 "KV write batch must contain 1..={MAX_KV_BATCH_MEMBERS} commands"
12770 )))
12771 }
12772}
12773
12774#[cfg(feature = "sql")]
12775fn validate_sql_batch_len(len: usize) -> Result<(), NodeError> {
12776 if (1..=MAX_TYPED_SQL_WRITE_BATCH_MEMBERS).contains(&len) {
12777 Ok(())
12778 } else {
12779 Err(NodeError::InvalidRequest(format!(
12780 "SQL write batch must contain 1..={MAX_TYPED_SQL_WRITE_BATCH_MEMBERS} commands"
12781 )))
12782 }
12783}
12784
12785fn validate_command_size(payload: &[u8]) -> Result<(), NodeError> {
12786 if payload.len() <= MAX_COMMAND_BYTES {
12787 Ok(())
12788 } else {
12789 Err(NodeError::InvalidRequest(format!(
12790 "command exceeds {MAX_COMMAND_BYTES} bytes"
12791 )))
12792 }
12793}
12794
12795#[cfg(feature = "sql")]
12796fn canonical_put(request_id: &str, key: &str, value: &str) -> Result<Vec<u8>, NodeError> {
12797 validate_field("request_id", request_id, MAX_REQUEST_ID_BYTES, false)?;
12798 validate_key(key)?;
12799 validate_field("value", value, MAX_VALUE_BYTES, true)?;
12800 let payload = encode_put_request(request_id, key, value)
12801 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
12802 validate_command_size(&payload)?;
12803 Ok(payload)
12804}
12805
12806#[cfg(feature = "sql")]
12807fn encode_sql_command_with_index(command: &SqlCommand) -> Result<Vec<u8>, NodeError> {
12808 encode_sql_command(command).map_err(|error| {
12809 let message = error.to_string();
12810 match first_invalid_sql_statement(command, |prefix| encode_sql_command(prefix).is_err()) {
12811 Some(statement_index) => NodeError::InvalidSqlStatement {
12812 statement_index,
12813 message,
12814 },
12815 None => NodeError::InvalidRequest(message),
12816 }
12817 })
12818}
12819
12820#[cfg(feature = "sql")]
12821fn first_invalid_sql_statement(
12822 command: &SqlCommand,
12823 mut invalid: impl FnMut(&SqlCommand) -> bool,
12824) -> Option<usize> {
12825 if command.statements.is_empty() || command.statements.len() > MAX_SQL_STATEMENTS {
12826 return None;
12827 }
12828 (0..command.statements.len()).find(|statement_index| {
12829 let prefix = SqlCommand {
12830 request_id: command.request_id.clone(),
12831 statements: command.statements[..=*statement_index].to_vec(),
12832 };
12833 invalid(&prefix)
12834 })
12835}
12836
12837#[cfg(feature = "sql")]
12838fn validate_key(key: &str) -> Result<(), NodeError> {
12839 validate_field("key", key, MAX_KEY_BYTES, false)
12840}
12841
12842#[cfg(feature = "sql")]
12843fn validate_field(
12844 name: &str,
12845 value: &str,
12846 max_bytes: usize,
12847 allow_empty: bool,
12848) -> Result<(), NodeError> {
12849 if !allow_empty && value.is_empty() {
12850 return Err(NodeError::InvalidRequest(format!(
12851 "{name} must not be empty"
12852 )));
12853 }
12854 if value.len() > max_bytes {
12855 return Err(NodeError::InvalidRequest(format!(
12856 "{name} exceeds {max_bytes} bytes"
12857 )));
12858 }
12859 if value.contains('\t') {
12860 return Err(NodeError::InvalidRequest(format!(
12861 "{name} must not contain a tab"
12862 )));
12863 }
12864 Ok(())
12865}
12866
12867#[cfg(feature = "sql")]
12868fn write_response(outcome: RequestOutcome) -> WriteResponse {
12869 WriteResponse {
12870 applied_index: outcome.original_log_index(),
12871 hash: outcome.original_log_hash(),
12872 }
12873}
12874
12875fn reconcile_local_storage(
12876 config: &NodeConfig,
12877 log_store: &FileLogStore,
12878 materializer: &Materializer,
12879) -> Result<(), NodeError> {
12880 let mut log_state = log_store
12881 .logical_state()
12882 .map_err(|error| NodeError::Storage(error.to_string()))?;
12883 let mut log_last_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
12884 let applied_index = materializer
12885 .applied_index()
12886 .map_err(|error| NodeError::Storage(error.to_string()))?;
12887 let applied_hash = materializer
12888 .applied_hash()
12889 .map_err(|error| NodeError::Storage(error.to_string()))?;
12890 let mut materializer_configuration = materializer
12891 .configuration_state()
12892 .map_err(|error| NodeError::Storage(error.to_string()))?;
12893
12894 if let Some(anchor) = &log_state.anchor {
12895 if anchor.recovery_generation() != config.recovery_generation {
12896 return Err(NodeError::Reconciliation(format!(
12897 "qlog anchor recovery generation {} differs from runtime generation {}",
12898 anchor.recovery_generation(),
12899 config.recovery_generation
12900 )));
12901 }
12902 if applied_index < anchor.compacted().index() {
12903 return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())));
12904 }
12905 }
12906 if applied_index > log_last_index {
12907 let entries: Option<Vec<LogEntry>> = match materializer {
12908 #[cfg(feature = "sql")]
12909 Materializer::Sql(sql) => Some(
12910 sql.embedded_log_entries(log_last_index.saturating_add(1), applied_index)
12911 .map_err(|error| NodeError::Reconciliation(error.to_string()))?,
12912 ),
12913 #[cfg(feature = "kv")]
12914 Materializer::Kv(kv) => Some(
12915 kv.embedded_log_entries(log_last_index.saturating_add(1), applied_index)
12916 .map_err(|error| NodeError::Reconciliation(error.to_string()))?,
12917 ),
12918 #[cfg(feature = "graph")]
12919 Materializer::Graph(_) => None,
12920 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
12921 Materializer::Unavailable => None,
12922 };
12923 if let Some(entries) = entries {
12924 log_store
12925 .append_batch(&entries)
12926 .map_err(|error| NodeError::Storage(error.to_string()))?;
12927 log_state = log_store
12928 .logical_state()
12929 .map_err(|error| NodeError::Storage(error.to_string()))?;
12930 log_last_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
12931 }
12932 }
12933 if applied_index > log_last_index {
12934 return Err(NodeError::Reconciliation(format!(
12935 "{} materializer is ahead at {applied_index}, qlog ends at {log_last_index}",
12936 materializer.profile()
12937 )));
12938 }
12939 if applied_index == 0 {
12940 if applied_hash != LogHash::ZERO {
12941 return Err(NodeError::Reconciliation(format!(
12942 "{} materializer genesis hash is not zero",
12943 materializer.profile()
12944 )));
12945 }
12946 } else if !log_state.anchor.as_ref().is_some_and(|anchor| {
12947 applied_index == anchor.compacted().index() && applied_hash == anchor.compacted().hash()
12948 }) {
12949 let entry = log_store
12950 .read(applied_index)
12951 .map_err(|error| NodeError::Storage(error.to_string()))?
12952 .ok_or_else(|| {
12953 NodeError::Reconciliation(format!(
12954 "qlog prefix is missing {} materializer index {applied_index}",
12955 materializer.profile()
12956 ))
12957 })?;
12958 validate_entry_envelope(config, &entry, applied_index, entry.prev_hash)?;
12959 if entry.hash != applied_hash {
12960 return Err(NodeError::Reconciliation(format!(
12961 "{} materializer hash diverges from qlog at index {applied_index}",
12962 materializer.profile()
12963 )));
12964 }
12965 }
12966
12967 let mut expected_prev_hash = applied_hash;
12968 for index in (applied_index + 1)..=log_last_index {
12969 let entry = log_store
12970 .read(index)
12971 .map_err(|error| NodeError::Storage(error.to_string()))?
12972 .ok_or_else(|| {
12973 NodeError::Reconciliation(format!("qlog prefix is missing index {index}"))
12974 })?;
12975 match &materializer_configuration {
12976 Some(configuration) => {
12977 validate_runtime_entry(config, configuration, &entry, index, expected_prev_hash)?
12978 }
12979 None => validate_entry_envelope(config, &entry, index, expected_prev_hash)?,
12980 };
12981 materializer
12982 .apply_entry(&entry)
12983 .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12984 materializer_configuration = materializer
12985 .configuration_state()
12986 .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12987 expected_prev_hash = entry.hash;
12988 }
12989 let log_configuration = log_store
12990 .configuration_state()
12991 .map_err(|error| NodeError::Storage(error.to_string()))?;
12992 if materializer_configuration
12993 .as_ref()
12994 .is_some_and(|configuration| configuration != &log_configuration)
12995 {
12996 return Err(NodeError::Reconciliation(format!(
12997 "qlog and {} materializer configuration states disagree",
12998 materializer.profile()
12999 )));
13000 }
13001 Ok(())
13002}
13003
13004fn recover_peer_candidates(
13005 config: &NodeConfig,
13006 consensus: &ThreeNodeConsensus,
13007 log_store: &FileLogStore,
13008 materializer: &Materializer,
13009 peer_candidates: &[&dyn LogPeer],
13010 startup: &StartupIoContext,
13011) -> Result<(), NodeError> {
13012 for peer in peer_candidates {
13013 let (last_index, last_hash) = static_log_tip(log_store)?;
13014 startup.check("peer log fetch")?;
13015 let candidates = match peer.fetch_log(FetchLogRequest {
13016 from_index: last_index.saturating_add(1),
13017 max_entries: MAX_FETCH_ENTRIES,
13018 }) {
13019 Ok(response) => validate_fetched_entries_with_configuration(
13020 last_index.saturating_add(1),
13021 last_hash,
13022 &config.cluster_id,
13023 config.epoch,
13024 log_store
13025 .configuration_state()
13026 .map_err(|error| NodeError::Storage(error.to_string()))?,
13027 response.entries,
13028 )
13029 .map_err(|error| {
13030 NodeError::Reconciliation(format!("peer candidate validation failed: {error}"))
13031 })?,
13032 Err(FetchLogError::Transport { .. }) => continue,
13033 Err(FetchLogError::SnapshotRequired { anchor }) => {
13034 return Err(NodeError::SnapshotRequired(anchor));
13035 }
13036 Err(error) => {
13037 return Err(NodeError::Reconciliation(format!(
13038 "peer candidate validation failed: {error}"
13039 )));
13040 }
13041 };
13042 startup.check("peer log fetch")?;
13043
13044 let mut expected_index = last_index.checked_add(1).ok_or_else(|| {
13045 NodeError::Reconciliation("qlog index is exhausted during peer catch-up".into())
13046 })?;
13047 let mut expected_prev_hash = last_hash;
13048 for candidate in candidates {
13049 startup.check("peer decision inspection")?;
13050 match consensus
13051 .inspect_decision_at(expected_index, expected_prev_hash)
13052 .map_err(startup_consensus_error)?
13053 {
13054 DecisionInspection::Committed(committed) if committed == candidate => {
13055 startup.check("peer decision inspection")?;
13056 persist_startup_entry(
13057 config,
13058 log_store,
13059 materializer,
13060 &candidate,
13061 expected_index,
13062 expected_prev_hash,
13063 startup,
13064 )?;
13065 expected_prev_hash = candidate.hash;
13066 expected_index = expected_index.checked_add(1).ok_or_else(|| {
13067 NodeError::Reconciliation(
13068 "qlog index is exhausted during peer catch-up".into(),
13069 )
13070 })?;
13071 }
13072 DecisionInspection::Committed(_) => {
13073 return Err(NodeError::Reconciliation(format!(
13074 "peer candidate at index {expected_index} differs from committed decision"
13075 )));
13076 }
13077 DecisionInspection::Unavailable => {
13078 return Err(NodeError::Unavailable(format!(
13079 "decision inspection unavailable for peer candidate at index {expected_index}"
13080 )));
13081 }
13082 DecisionInspection::Empty | DecisionInspection::Pending => {
13083 return Err(NodeError::Reconciliation(format!(
13084 "peer candidate at index {expected_index} is not committed"
13085 )));
13086 }
13087 }
13088 }
13089 }
13090 Ok(())
13091}
13092
13093fn recover_startup_decisions(
13094 config: &NodeConfig,
13095 consensus: &ThreeNodeConsensus,
13096 log_store: &FileLogStore,
13097 materializer: &Materializer,
13098 startup: &StartupIoContext,
13099) -> Result<(), NodeError> {
13100 for _ in 0..MAX_STARTUP_RECOVERY_ENTRIES {
13101 let (last_index, last_hash) = static_log_tip(log_store)?;
13102 let slot = last_index.checked_add(1).ok_or_else(|| {
13103 NodeError::Reconciliation("qlog index is exhausted during startup".into())
13104 })?;
13105 startup.check("recorder decision inspection")?;
13106 match consensus
13107 .inspect_decision_at(slot, last_hash)
13108 .map_err(startup_consensus_error)?
13109 {
13110 DecisionInspection::Committed(entry) => {
13111 startup.check("recorder decision inspection")?;
13112 persist_startup_entry(
13113 config,
13114 log_store,
13115 materializer,
13116 &entry,
13117 slot,
13118 last_hash,
13119 startup,
13120 )?;
13121 }
13122 DecisionInspection::Pending => {
13123 let entry = consensus
13124 .propose_at_cancellable(
13125 slot,
13126 last_hash,
13127 Command::new(CommandKind::ReadBarrier, Vec::new()),
13128 startup.cancellation_flag(),
13129 )
13130 .map_err(startup_consensus_error)?;
13131 startup.check("recorder pending decision recovery")?;
13132 persist_startup_entry(
13133 config,
13134 log_store,
13135 materializer,
13136 &entry,
13137 slot,
13138 last_hash,
13139 startup,
13140 )?;
13141 }
13142 DecisionInspection::Empty => return Ok(()),
13143 DecisionInspection::Unavailable => {
13144 return Err(NodeError::Unavailable(
13145 "decision inspection unavailable during startup".into(),
13146 ));
13147 }
13148 }
13149 }
13150 Err(NodeError::Reconciliation(format!(
13151 "startup recovery exceeded {MAX_STARTUP_RECOVERY_ENTRIES} entries"
13152 )))
13153}
13154
13155fn persist_startup_entry(
13156 config: &NodeConfig,
13157 log_store: &FileLogStore,
13158 materializer: &Materializer,
13159 entry: &LogEntry,
13160 expected_index: LogIndex,
13161 expected_prev_hash: LogHash,
13162 startup: &StartupIoContext,
13163) -> Result<(), NodeError> {
13164 startup.persist("startup qlog and materializer persistence", || {
13165 let configuration_state = log_store
13166 .configuration_state()
13167 .map_err(|error| NodeError::Storage(error.to_string()))?;
13168 validate_runtime_entry(
13169 config,
13170 &configuration_state,
13171 entry,
13172 expected_index,
13173 expected_prev_hash,
13174 )?;
13175 log_store
13176 .append(entry)
13177 .map_err(|error| NodeError::Storage(error.to_string()))?;
13178 materializer
13179 .apply_entry(entry)
13180 .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
13181 Ok(())
13182 })
13183}
13184
13185fn static_log_tip(log_store: &FileLogStore) -> Result<(LogIndex, LogHash), NodeError> {
13186 Ok(log_store
13187 .logical_state()
13188 .map_err(|error| NodeError::Storage(error.to_string()))?
13189 .tip
13190 .map_or((0, LogHash::ZERO), |tip| (tip.index(), tip.hash())))
13191}
13192
13193fn validate_runtime_entry(
13194 config: &NodeConfig,
13195 configuration_state: &ConfigurationState,
13196 entry: &LogEntry,
13197 expected_index: LogIndex,
13198 expected_prev_hash: LogHash,
13199) -> Result<(), NodeError> {
13200 validate_entry_envelope(config, entry, expected_index, expected_prev_hash)?;
13201 validate_profile_entry_shape(config.execution_profile(), entry)
13202 .map_err(NodeError::Invariant)?;
13203 configuration_state
13204 .validate_entry(entry)
13205 .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
13206 Ok(())
13207}
13208
13209fn validate_entry_envelope(
13210 config: &NodeConfig,
13211 entry: &LogEntry,
13212 expected_index: LogIndex,
13213 expected_prev_hash: LogHash,
13214) -> Result<(), NodeError> {
13215 if entry.index != expected_index {
13216 return Err(NodeError::Reconciliation(format!(
13217 "expected decision index {expected_index}, got {}",
13218 entry.index
13219 )));
13220 }
13221 if entry.cluster_id != config.cluster_id || entry.epoch != config.epoch {
13222 return Err(NodeError::Reconciliation(format!(
13223 "decision {} has a foreign identity",
13224 entry.index
13225 )));
13226 }
13227 if entry.prev_hash != expected_prev_hash {
13228 return Err(NodeError::Reconciliation(format!(
13229 "decision {} has a conflicting predecessor",
13230 entry.index
13231 )));
13232 }
13233 if entry.recompute_hash() != entry.hash {
13234 return Err(NodeError::Reconciliation(format!(
13235 "decision {} has an invalid hash",
13236 entry.index
13237 )));
13238 }
13239 validate_entry_shape(entry).map_err(NodeError::Invariant)
13240}
13241
13242fn validate_entry_shape(entry: &LogEntry) -> Result<(), String> {
13243 match entry.entry_type {
13244 EntryType::Command if entry.payload.len() <= MAX_COMMAND_BYTES => Ok(()),
13245 EntryType::Command => Err(format!("command exceeds {MAX_COMMAND_BYTES} bytes")),
13246 EntryType::Noop if entry.payload.is_empty() => Ok(()),
13247 EntryType::Noop => Err("Noop payload must be empty".into()),
13248 EntryType::ConfigChange => ConfigChange::recognize(&StoredCommand::new(
13249 EntryType::ConfigChange,
13250 entry.payload.clone(),
13251 ))
13252 .map(|_| ())
13253 .map_err(|error| error.to_string()),
13254 other => Err(format!("unsupported runtime entry type {other:?}")),
13255 }
13256}
13257
13258pub(crate) fn validate_profile_entry_shape(
13259 _profile: ExecutionProfile,
13260 entry: &LogEntry,
13261) -> Result<(), String> {
13262 validate_entry_shape(entry)?;
13263 #[cfg(feature = "sql")]
13264 if _profile == ExecutionProfile::Sqlite && entry.entry_type == EntryType::Command {
13265 decode_qwal_v3(&entry.payload)
13266 .map_err(|error| format!("SQLite command is not canonical QWAL v3: {error}"))?;
13267 }
13268 Ok(())
13269}
13270
13271fn startup_consensus_error(error: rhiza_quepaxa::Error) -> NodeError {
13272 match error {
13273 rhiza_quepaxa::Error::NoQuorum
13274 | rhiza_quepaxa::Error::CommandUnavailable
13275 | rhiza_quepaxa::Error::Cancelled
13276 | rhiza_quepaxa::Error::Io(_) => NodeError::Unavailable(error.to_string()),
13277 other => NodeError::Reconciliation(other.to_string()),
13278 }
13279}
13280
13281#[cfg(feature = "sql")]
13282#[derive(Clone, Debug, Eq, PartialEq)]
13283pub struct E2eConfig {
13284 pub data_dir: PathBuf,
13285 pub object_store: ObjStoreConfig,
13286 pub cluster_id: String,
13287 pub node_id: String,
13288}
13289
13290#[cfg(feature = "sql")]
13291#[derive(Clone, Debug, Eq, PartialEq)]
13292pub struct E2eReport {
13293 pub applied_index: LogIndex,
13294 pub restored_value: String,
13295 pub object_keys: Vec<String>,
13296}
13297
13298#[cfg(feature = "sql")]
13299pub async fn run_e2e(config: E2eConfig) -> Result<E2eReport, Box<dyn std::error::Error>> {
13300 let sqlite_dir = config.data_dir.join("sqlite");
13301 let log_dir = config.data_dir.join("consensus").join("log");
13302 ensure_fresh_e2e_data_dir(&config.data_dir, &sqlite_dir, &log_dir)?;
13303
13304 fs::create_dir_all(&config.data_dir)?;
13305 let db_path = sqlite_dir.join("db.sqlite");
13306 let restore_path = config.data_dir.join("restore").join("db.sqlite");
13307 let db = SqliteStateMachine::open(&db_path, &config.cluster_id, &config.node_id, 1, 1)?;
13308 let recorder_dir = config.data_dir.join("consensus").join("recorder");
13309 let consensus = ThreeNodeConsensus::new(
13310 &config.cluster_id,
13311 &config.node_id,
13312 1,
13313 1,
13314 [
13315 recorder_dir.join("node-1"),
13316 recorder_dir.join("node-2"),
13317 recorder_dir.join("node-3"),
13318 ],
13319 )?;
13320 let base_request = canonical_put("e2e-base", "alpha", "bravo")?;
13321 let base_effect = db.prepare_put_effect(
13322 "e2e-base",
13323 "alpha",
13324 "bravo",
13325 &base_request,
13326 0,
13327 LogHash::ZERO,
13328 )?;
13329 let base_entry = consensus.propose(Command::new(CommandKind::Deterministic, base_effect))?;
13330 db.apply_entry(&base_entry)?;
13331 let snapshot = db.create_snapshot(base_entry.index)?;
13332
13333 let tail_request = canonical_put("e2e-tail", "alpha", "charlie")?;
13334 let tail_effect = db.prepare_put_effect(
13335 "e2e-tail",
13336 "alpha",
13337 "charlie",
13338 &tail_request,
13339 base_entry.index,
13340 base_entry.hash,
13341 )?;
13342 let tail_entry = consensus.propose(Command::new(CommandKind::Deterministic, tail_effect))?;
13343 let segment_path = write_segment_file(&log_dir, std::slice::from_ref(&tail_entry))?;
13344 let segment = rhiza_log::SegmentFile::new(
13345 IndexRange::new(tail_entry.index, tail_entry.index)?,
13346 fs::read(&segment_path)?,
13347 );
13348 db.apply_entry(&tail_entry)?;
13349
13350 let local_archive = matches!(&config.object_store, ObjStoreConfig::Local { .. });
13351 let store = ObjStore::new(config.object_store)?;
13352 let archive = if local_archive {
13353 rhiza_archive::ObjectArchiveStore::new_for_single_process(
13354 store.clone(),
13355 config.cluster_id.clone(),
13356 )
13357 } else {
13358 rhiza_archive::ObjectArchiveStore::new(store.clone(), config.cluster_id.clone())?
13359 };
13360
13361 let segment_record = archive.publish_segment(tail_entry.epoch, &segment).await?;
13362 let snapshot_record = archive.publish_snapshot(&snapshot).await?;
13363 let (mut archive_manifest, expected_manifest_version) = match archive.load_manifest().await? {
13364 Some(loaded) => (loaded.manifest().clone(), Some(loaded.version().clone())),
13365 None => (
13366 rhiza_archive::ArchiveManifest::new(config.cluster_id.clone()),
13367 None,
13368 ),
13369 };
13370 archive_manifest.set_latest_snapshot(snapshot_record);
13371 archive_manifest.add_segment(segment_record);
13372 archive
13373 .publish_manifest(&archive_manifest, expected_manifest_version)
13374 .await?;
13375
13376 let loaded_manifest = archive
13377 .load_manifest()
13378 .await?
13379 .ok_or("published archive manifest is missing")?;
13380 if loaded_manifest.manifest() != &archive_manifest {
13381 return Err("reloaded archive manifest did not match the published manifest".into());
13382 }
13383 let archived_snapshot = loaded_manifest
13384 .manifest()
13385 .latest_snapshot()
13386 .ok_or("archive manifest is missing its snapshot")?;
13387 let archived_segment = loaded_manifest
13388 .manifest()
13389 .segments()
13390 .iter()
13391 .find(|record| {
13392 record.start_index() == tail_entry.index && record.end_index() == tail_entry.index
13393 })
13394 .ok_or("archive manifest is missing its post-snapshot segment")?;
13395
13396 let downloaded_segment = archive.download_segment(archived_segment).await?;
13397 let downloaded_entries = decode_segment_for_cluster(&downloaded_segment, &config.cluster_id)?;
13398 if downloaded_entries.as_slice() != std::slice::from_ref(&tail_entry) {
13399 return Err("downloaded qlog segment did not match written entry".into());
13400 }
13401 let downloaded_snapshot = rhiza_core::Snapshot::new(
13402 archived_snapshot.manifest().clone(),
13403 archive.download_snapshot(archived_snapshot).await?,
13404 );
13405 restore_snapshot_file(&restore_path, &downloaded_snapshot, &config.node_id)?;
13406 let restored_db = SqliteStateMachine::open_existing(&restore_path)?;
13407 if restored_db.get_value("alpha")?.as_deref() != Some("bravo") {
13408 return Err("restored base snapshot is missing alpha=bravo".into());
13409 }
13410 for entry in &downloaded_entries {
13411 restored_db.apply_entry(entry)?;
13412 }
13413 let restored_value = restored_db
13414 .get_value("alpha")?
13415 .ok_or("restored SQLite state is missing alpha")?;
13416 let applied_index = restored_db.applied_index_value()?;
13417 if applied_index != tail_entry.index || restored_value != "charlie" {
13418 return Err("restored SQLite state did not include the archived log tail".into());
13419 }
13420 let object_keys = store.list(&format!("rhiza/{}", config.cluster_id)).await?;
13421
13422 Ok(E2eReport {
13423 applied_index,
13424 restored_value,
13425 object_keys,
13426 })
13427}
13428
13429#[cfg(feature = "sql")]
13430fn ensure_fresh_e2e_data_dir(
13431 data_dir: &std::path::Path,
13432 sqlite_dir: &std::path::Path,
13433 log_dir: &std::path::Path,
13434) -> Result<(), Box<dyn std::error::Error>> {
13435 if directory_has_entries(sqlite_dir)? || directory_has_entries(log_dir)? {
13436 return Err(format!(
13437 "e2e data directory is not fresh: prior SQLite/qlog data exists in {}",
13438 data_dir.display()
13439 )
13440 .into());
13441 }
13442 Ok(())
13443}
13444
13445#[cfg(feature = "sql")]
13446fn directory_has_entries(path: &std::path::Path) -> Result<bool, std::io::Error> {
13447 match fs::read_dir(path) {
13448 Ok(mut entries) => entries.next().transpose().map(|entry| entry.is_some()),
13449 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
13450 Err(error) => Err(error),
13451 }
13452}