1use std::{
2 collections::{HashMap, HashSet},
3 fmt, fs,
4 path::{Path, PathBuf},
5 sync::{
6 atomic::{AtomicBool, Ordering},
7 Arc, Condvar, Mutex, MutexGuard, OnceLock,
8 },
9 time::{Duration, Instant},
10};
11
12#[cfg(feature = "sql")]
13use std::collections::VecDeque;
14
15#[cfg(not(feature = "sql"))]
16compile_error!("rhiza-node requires the sql execution profile feature");
17
18#[cfg(feature = "graph")]
19use std::collections::BTreeMap;
20#[cfg(any(feature = "sql", test))]
21use std::sync::atomic::AtomicUsize;
22
23use axum::{
24 extract::{rejection::JsonRejection, DefaultBodyLimit, Extension, Request, State},
25 http::{HeaderMap, StatusCode},
26 middleware::{self, Next},
27 response::{IntoResponse, Response},
28 routing::{get, post},
29 Json, Router,
30};
31#[cfg(feature = "sql")]
32use rhiza_archive::SnapshotRecord;
33use rhiza_core::{
34 Command, CommandKind, ConfigChange, ConfigurationState, EntryType, ErrorClassification,
35 ExecutionProfile, LogAnchor, LogEntry, LogHash, LogIndex, RecoveryAnchor, StoredCommand,
36};
37#[cfg(feature = "graph")]
38use rhiza_graph::{
39 encode_replicated_graph_batch, encode_replicated_graph_command, CanonicalF64, GraphColumn,
40 GraphInternalId, GraphLogicalType, GraphNode, GraphParameterValue, GraphQueryResult, GraphRel,
41 GraphResultValue, LadybugStateMachine, RequestRecord as GraphRequestRecord,
42};
43#[cfg(feature = "kv")]
44use rhiza_kv::{
45 encode_replicated_kv_batch, encode_replicated_kv_command, KvRequestRecord, RedbStateMachine,
46 MAX_KV_BATCH_MEMBERS,
47};
48#[cfg(feature = "kv")]
49pub use rhiza_kv::{KvScanResult, KvScanRow, MAX_KV_SCAN_RESULT_BYTES, MAX_KV_SCAN_ROWS};
50#[cfg(feature = "sql")]
51use rhiza_log::{decode_segment_for_cluster, write_segment_file};
52use rhiza_log::{FileLogStore, IndexRange, LogStore};
53#[cfg(feature = "sql")]
54use rhiza_obj_store::{ObjStore, ObjStoreConfig};
55#[cfg(feature = "sql")]
56use rhiza_quepaxa::Consensus;
57use rhiza_quepaxa::{
58 CertifiedDecisionInspection, DecisionInspection, DecisionProof, Membership,
59 ReadFenceObservation, ReadFenceRequest, RecordRequest, RecordSummary, RecorderFileStore,
60 RecorderRpc, RejectReason, ThreeNodeConsensus,
61};
62#[cfg(feature = "sql")]
63use rhiza_sql::{
64 decode_qwal_v3, encode_put_request, encode_sql_command, restore_snapshot_file,
65 RecoverySnapshot, RequestConflict, RequestOutcome, SqlBatchMember, SqlCommand,
66 SqlCommandResult, SqlQueryResult, SqlStatement, SqlValue, SqliteStateMachine,
67 MAX_QWAL_V3_RECEIPTS, MAX_SQL_STATEMENTS, QWAL_V3_MAGIC,
68};
69#[cfg(not(feature = "sql"))]
70type SqlCommandResult = ();
71
72mod admin;
73pub mod durability;
74#[cfg(feature = "graph")]
75mod graph;
76#[cfg(feature = "kv")]
77mod kv;
78mod learner;
79mod recorder_tcp;
80pub use admin::*;
81pub use durability::{
82 restore_checkpoint_to_fresh_data_dir, restore_checkpoint_to_fresh_data_dir_for_node,
83 CheckpointCoordinator, DurabilityError, DurabilityHealth, DurabilityMode,
84 SuccessorRestorePreparation,
85};
86#[cfg(feature = "graph")]
87pub use graph::*;
88#[cfg(feature = "kv")]
89pub use kv::*;
90pub use learner::*;
91#[cfg(feature = "recorder-postcard-rpc")]
92pub use recorder_tcp::{
93 serve_recorder_postcard_rpc, serve_recorder_postcard_rpc_tls,
94 RecorderPostcardRpcTlsClientConfig, RecorderPostcardRpcTlsServerConfig,
95 TcpPostcardRpcRecorderClient,
96};
97pub use recorder_tcp::{
98 serve_recorder_tcp, serve_recorder_tcp_tls, validate_recorder_tcp_endpoint,
99 RecorderTlsClientConfig, RecorderTlsServerConfig, TcpPostcardRecorderClient,
100};
101
102pub const MAX_FETCH_ENTRIES: u32 = 1_024;
103pub const MAX_COMMAND_BYTES: usize = 512 * 1024;
104pub const MAX_REQUEST_ID_BYTES: usize = 256;
105pub const MAX_KEY_BYTES: usize = 4 * 1024;
106pub const MAX_VALUE_BYTES: usize = 240 * 1024;
107pub const MAX_HTTP_BODY_BYTES: usize = MAX_COMMAND_BYTES * 6 + 16 * 1024;
108pub const DEFAULT_CLIENT_CONCURRENCY: usize = 16;
109pub const DEFAULT_PEER_CONCURRENCY: usize = 32;
110pub const DEFAULT_WRITER_BATCH_MAX: usize = 8;
111const MAX_WRITE_BATCH_MEMBERS: usize = 64;
112#[cfg(feature = "sql")]
113const MAX_SQL_WRITE_BATCH_MEMBERS: usize = MAX_QWAL_V3_RECEIPTS;
114#[cfg(feature = "sql")]
115pub const MAX_TYPED_SQL_WRITE_BATCH_MEMBERS: usize = 256;
116#[cfg(feature = "sql")]
117pub const DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY: usize = 64;
118#[cfg(feature = "sql")]
119pub const MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY: usize = 4_096;
120#[cfg(feature = "sql")]
122const MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES: usize = 4 * MAX_COMMAND_BYTES;
123#[cfg(feature = "sql")]
125const MAX_SQL_GROUP_COMMIT_PENDING_BYTES: usize =
126 DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY * MAX_COMMAND_BYTES;
127#[cfg(feature = "kv")]
128const MAX_KV_GROUP_COMMIT_MEMBERS: usize = 1_024;
129#[cfg(feature = "kv")]
130const KV_GROUP_COMMIT_QUEUE_CAPACITY: usize = 64;
131#[cfg(feature = "kv")]
132const MAX_KV_GROUP_COMMIT_PENDING_BYTES: usize = KV_GROUP_COMMIT_QUEUE_CAPACITY * MAX_COMMAND_BYTES;
133pub const DEFAULT_WRITER_BATCH_WINDOW: Duration = Duration::from_micros(500);
134pub const PROTOCOL_VERSION: &str = "1";
135pub const RECORDER_PROTOCOL_VERSION: &str = "3";
136const RECORDER_WIRE_VERSION: u16 = 3;
137pub const VERSION_HEADER: &str = "x-rhiza-version";
138pub const NODE_ID_HEADER: &str = "x-rhiza-node-id";
139pub const RECOVERY_GENERATION_HEADER: &str = "x-rhiza-recovery-generation";
140pub const RECORDER_IDENTITY_PATH: &str = "/v2/quepaxa/recorder/identity";
141
142#[cfg(feature = "sql")]
147#[derive(Clone)]
148pub struct SqlWriteProfiler {
149 inner: Arc<SqlWriteProfilerInner>,
150}
151
152#[cfg(feature = "sql")]
153struct SqlWriteProfilerInner {
154 capacity: usize,
155 state: Mutex<SqlWriteProfilerState>,
156}
157
158#[cfg(feature = "sql")]
159#[derive(Default)]
160struct SqlWriteProfilerState {
161 samples: VecDeque<SqlWriteProfileSample>,
162 dropped_samples: u64,
163}
164
165#[cfg(feature = "sql")]
167#[derive(Clone, Debug, Eq, PartialEq)]
168pub struct SqlWriteProfileSample {
169 pub batch_member_count: usize,
170 pub commit_lock_wait_us: u64,
171 pub precheck_classification_us: u64,
172 pub qwal_prepare_us: u64,
173 pub consensus_propose_us: u64,
174 pub local_qlog_mirror_append_us: u64,
175 pub sql_materializer_apply_us: u64,
176 pub response_other_total_us: u64,
177 pub total_service_us: u64,
178}
179
180#[cfg(feature = "sql")]
182#[derive(Clone, Debug, Default, Eq, PartialEq)]
183pub struct SqlWriteProfileSnapshot {
184 pub samples: Vec<SqlWriteProfileSample>,
185 pub dropped_samples: u64,
186}
187
188#[cfg(feature = "sql")]
189impl SqlWriteProfiler {
190 pub fn new(capacity: usize) -> Self {
196 assert!(capacity > 0, "SQL write profiler capacity must be non-zero");
197 Self {
198 inner: Arc::new(SqlWriteProfilerInner {
199 capacity,
200 state: Mutex::new(SqlWriteProfilerState::default()),
201 }),
202 }
203 }
204
205 pub fn snapshot(&self) -> SqlWriteProfileSnapshot {
206 let state = self
207 .inner
208 .state
209 .lock()
210 .unwrap_or_else(std::sync::PoisonError::into_inner);
211 SqlWriteProfileSnapshot {
212 samples: state.samples.iter().cloned().collect(),
213 dropped_samples: state.dropped_samples,
214 }
215 }
216
217 pub fn drain(&self) -> SqlWriteProfileSnapshot {
219 let mut state = self
220 .inner
221 .state
222 .lock()
223 .unwrap_or_else(std::sync::PoisonError::into_inner);
224 SqlWriteProfileSnapshot {
225 samples: state.samples.drain(..).collect(),
226 dropped_samples: state.dropped_samples,
227 }
228 }
229
230 fn record(&self, sample: SqlWriteProfileSample) {
231 let mut state = self
232 .inner
233 .state
234 .lock()
235 .unwrap_or_else(std::sync::PoisonError::into_inner);
236 if state.samples.len() == self.inner.capacity {
237 state.samples.pop_front();
238 state.dropped_samples = state.dropped_samples.saturating_add(1);
239 }
240 state.samples.push_back(sample);
241 }
242}
243
244#[cfg(feature = "sql")]
245impl fmt::Debug for SqlWriteProfiler {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 f.debug_struct("SqlWriteProfiler")
248 .field("capacity", &self.inner.capacity)
249 .finish_non_exhaustive()
250 }
251}
252
253#[cfg(feature = "sql")]
254impl PartialEq for SqlWriteProfiler {
255 fn eq(&self, other: &Self) -> bool {
256 Arc::ptr_eq(&self.inner, &other.inner)
257 }
258}
259
260#[cfg(feature = "sql")]
261impl Eq for SqlWriteProfiler {}
262pub const RECORDER_STORE_COMMAND_PATH: &str = "/v2/quepaxa/recorder/store-command";
263pub const RECORDER_FETCH_COMMAND_PATH: &str = "/v2/quepaxa/recorder/fetch-command";
264pub const RECORDER_INSPECT_PROOF_PATH: &str = "/v2/quepaxa/recorder/inspect-proof";
265pub const RECORDER_INSPECT_RECORD_PATH: &str = "/v2/quepaxa/recorder/inspect-record";
266pub const RECORDER_READ_FENCE_PATH: &str = "/v3/quepaxa/recorder/read-fence";
267pub const RECORDER_RECORD_PATH: &str = "/v2/quepaxa/recorder/record";
268pub const RECORDER_INSTALL_PROOF_PATH: &str = "/v2/quepaxa/recorder/install-decision-proof";
269pub const LOG_FETCH_PATH: &str = "/v1/log/fetch";
270#[cfg(feature = "sql")]
271pub const WRITE_PATH: &str = "/v1/write";
272#[cfg(feature = "sql")]
273pub const READ_PATH: &str = "/v1/read";
274#[cfg(feature = "sql")]
275pub const SQL_EXECUTE_PATH: &str = "/v1/sql/execute";
276#[cfg(feature = "sql")]
277pub const SQL_QUERY_PATH: &str = "/v1/sql/query";
278#[cfg(feature = "graph")]
279pub const GRAPH_PUT_DOCUMENT_PATH: &str = "/v1/graph/documents/put";
280#[cfg(feature = "graph")]
281pub const GRAPH_DELETE_DOCUMENT_PATH: &str = "/v1/graph/documents/delete";
282#[cfg(feature = "graph")]
283pub const GRAPH_GET_DOCUMENT_PATH: &str = "/v1/graph/documents/get";
284#[cfg(feature = "graph")]
285pub const GRAPH_QUERY_PATH: &str = "/v1/graph/query";
286#[cfg(feature = "kv")]
287pub const KV_PUT_PATH: &str = "/v1/kv/put";
288#[cfg(feature = "kv")]
289pub const KV_DELETE_PATH: &str = "/v1/kv/delete";
290#[cfg(feature = "kv")]
291pub const KV_GET_PATH: &str = "/v1/kv/get";
292#[cfg(feature = "kv")]
293pub const KV_SCAN_PATH: &str = "/v1/kv/scan";
294pub const LIVEZ_PATH: &str = "/livez";
295pub const READYZ_PATH: &str = "/readyz";
296const MAX_STARTUP_RECOVERY_ENTRIES: usize = 100_000;
297const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
298const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
299const READ_FENCE_REQUEST_TIMEOUT: Duration = Duration::from_secs(1);
300const QUORUM_RECORD_REQUEST_TIMEOUT: Duration = Duration::from_millis(250);
303const CLIENT_WRITE_WAIT_TIMEOUT: Duration = Duration::from_secs(1);
304const SYNC_FLUSH_RETRY_INITIAL: Duration = Duration::from_millis(50);
305const SYNC_FLUSH_RETRY_MAX: Duration = Duration::from_secs(1);
306
307fn map_quorum_record_transport_error(error: rhiza_quepaxa::Error) -> rhiza_quepaxa::Error {
308 match error {
309 rhiza_quepaxa::Error::Io(_) | rhiza_quepaxa::Error::Decode(_) => {
310 rhiza_quepaxa::Error::ProposeFailed
311 }
312 error => error,
313 }
314}
315#[cfg(feature = "sql")]
316pub const DEFAULT_SQL_MAX_ROWS: u32 = 1_000;
317#[cfg(feature = "sql")]
318pub const MAX_SQL_MAX_ROWS: u32 = 10_000;
319#[cfg(feature = "sql")]
320pub const MAX_SQL_RESULT_BYTES: usize = 1024 * 1024;
321#[cfg(feature = "sql")]
322pub const MAX_SQL_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
323#[cfg(feature = "kv")]
324type KvMemberCheck = (usize, Result<Option<KvRequestRecord>, NodeError>);
325#[cfg(feature = "kv")]
326pub const DEFAULT_KV_SCAN_LIMIT: u32 = 100;
327#[cfg(feature = "kv")]
328pub const MAX_KV_SCAN_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
329#[cfg(feature = "graph")]
330pub const DEFAULT_GRAPH_MAX_ROWS: u32 = 1_000;
331#[cfg(feature = "graph")]
332pub const MAX_GRAPH_MAX_ROWS: u32 = 10_000;
333#[cfg(feature = "graph")]
334pub const MAX_GRAPH_RESULT_BYTES: usize = 1024 * 1024;
335#[cfg(feature = "graph")]
336pub const MAX_GRAPH_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
337#[cfg(feature = "graph")]
338const GRAPH_QUERY_TIMEOUT_MS: u64 = 5_000;
339
340pub fn effective_cluster_id(
341 profile: ExecutionProfile,
342 logical_cluster_id: &str,
343) -> Result<String, ConfigError> {
344 if let Some(actual) = canonical_cluster_profile(logical_cluster_id) {
345 if actual != profile {
346 return Err(ConfigError::ClusterIdProfileMismatch {
347 expected: profile,
348 actual,
349 });
350 }
351 return Ok(logical_cluster_id.to_owned());
352 }
353 Ok(format!("rhiza:{}:{logical_cluster_id}", profile.as_str()))
354}
355
356pub const fn execution_profile_compiled(profile: ExecutionProfile) -> bool {
357 match profile {
358 ExecutionProfile::Sqlite => cfg!(feature = "sql"),
359 ExecutionProfile::Graph => cfg!(feature = "graph"),
360 ExecutionProfile::Kv => cfg!(feature = "kv"),
361 }
362}
363
364fn canonical_cluster_profile(cluster_id: &str) -> Option<ExecutionProfile> {
365 [
366 ("rhiza:sql:", ExecutionProfile::Sqlite),
367 ("rhiza:graph:", ExecutionProfile::Graph),
368 ("rhiza:kv:", ExecutionProfile::Kv),
369 ]
370 .into_iter()
371 .find_map(|(prefix, profile)| cluster_id.starts_with(prefix).then_some(profile))
372}
373
374#[derive(Clone, Debug, Eq, PartialEq)]
375pub enum ConfigError {
376 EmptyClusterId,
377 ClusterIdProfileMismatch {
378 expected: ExecutionProfile,
379 actual: ExecutionProfile,
380 },
381 EmptyNodeId,
382 EmptyDataDir,
383 InvalidEpoch,
384 InvalidConfigId,
385 InvalidRecoveryGeneration,
386 InvalidWriterBatchMax(usize),
387 InvalidWriterBatchWindow,
388 #[cfg(feature = "sql")]
389 InvalidSqlGroupCommitQueueCapacity(usize),
390 EmptyPeerNodeId,
391 EmptyPeerBaseUrl,
392 InvalidPeerBaseUrl(String),
393 EmptyPeerToken,
394 DuplicatePeerToken,
395 InvalidPeerCount(usize),
396 DuplicatePeerNodeId(String),
397 LocalNodeMissing,
398 PeerMembershipMismatch,
399 InvalidPredecessorTransition(String),
400 EmptyClientToken,
401 ClientTokenConflictsWithPeer,
402 EmptyAdminToken,
403 AdminTokenConflictsWithRuntime,
404 HttpClient(String),
405}
406
407impl fmt::Display for ConfigError {
408 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409 match self {
410 Self::EmptyClusterId => write!(f, "cluster_id must not be empty"),
411 Self::ClusterIdProfileMismatch { expected, actual } => write!(
412 f,
413 "cluster_id is canonical for the {} profile, not {}",
414 actual.as_str(),
415 expected.as_str()
416 ),
417 Self::EmptyNodeId => write!(f, "node_id must not be empty"),
418 Self::EmptyDataDir => write!(f, "data_dir must not be empty"),
419 Self::InvalidEpoch => write!(f, "epoch must be positive"),
420 Self::InvalidConfigId => write!(f, "config_id must be positive"),
421 Self::InvalidRecoveryGeneration => {
422 write!(f, "recovery_generation must be positive")
423 }
424 Self::InvalidWriterBatchMax(max) => write!(
425 f,
426 "writer batch max must be within 1..={MAX_WRITE_BATCH_MEMBERS}, got {max}"
427 ),
428 Self::InvalidWriterBatchWindow => write!(
429 f,
430 "writer batch window must be positive and shorter than the client deadline"
431 ),
432 #[cfg(feature = "sql")]
433 Self::InvalidSqlGroupCommitQueueCapacity(capacity) => write!(
434 f,
435 "SQL group commit queue capacity must be within 1..={MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY}, got {capacity}"
436 ),
437 Self::EmptyPeerNodeId => write!(f, "peer node_id must not be empty"),
438 Self::EmptyPeerBaseUrl => write!(f, "peer base_url must not be empty"),
439 Self::InvalidPeerBaseUrl(url) => write!(f, "invalid peer base_url: {url}"),
440 Self::EmptyPeerToken => write!(f, "peer token must not be empty"),
441 Self::DuplicatePeerToken => write!(f, "peer tokens must be unique"),
442 Self::InvalidPeerCount(count) => {
443 write!(
444 f,
445 "peer membership requires between three and seven nodes, got {count}"
446 )
447 }
448 Self::DuplicatePeerNodeId(node_id) => {
449 write!(f, "peer node_id must be unique: {node_id}")
450 }
451 Self::LocalNodeMissing => write!(f, "peer set must include the local node_id"),
452 Self::PeerMembershipMismatch => {
453 write!(
454 f,
455 "peer identities must exactly match the canonical membership"
456 )
457 }
458 Self::InvalidPredecessorTransition(message) => {
459 write!(f, "invalid predecessor transition: {message}")
460 }
461 Self::EmptyClientToken => write!(f, "client token must not be empty"),
462 Self::ClientTokenConflictsWithPeer => {
463 write!(f, "client token must differ from every peer token")
464 }
465 Self::EmptyAdminToken => write!(f, "admin token must not be empty"),
466 Self::AdminTokenConflictsWithRuntime => {
467 write!(f, "admin token must differ from client and peer tokens")
468 }
469 Self::HttpClient(message) => write!(f, "HTTP client configuration failed: {message}"),
470 }
471 }
472}
473
474impl std::error::Error for ConfigError {}
475
476fn validate_recovery_generation(recovery_generation: u64) -> Result<(), ConfigError> {
477 if recovery_generation == 0 {
478 Err(ConfigError::InvalidRecoveryGeneration)
479 } else {
480 Ok(())
481 }
482}
483
484#[derive(Clone, Copy, Debug, Eq, PartialEq)]
485pub enum AckMode {
486 HaFirst,
487 DrStrong,
488}
489
490#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
491#[serde(rename_all = "snake_case")]
492pub enum ReadConsistency {
493 Local,
494 ReadBarrier,
495 AppliedIndex(LogIndex),
496}
497
498#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
499pub struct FetchLogRequest {
500 pub from_index: LogIndex,
501 pub max_entries: u32,
502}
503
504#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
505pub struct FetchLogResponse {
506 pub entries: Vec<LogEntry>,
507 pub last_index: LogIndex,
508}
509
510#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
511pub enum FetchLogError {
512 SnapshotRequired {
513 anchor: Box<RecoveryAnchor>,
514 },
515 Gap {
516 expected: LogIndex,
517 actual: Option<LogIndex>,
518 },
519 Decode {
520 message: String,
521 },
522 Transport {
523 message: String,
524 },
525 InvalidAnchor {
526 expected: LogHash,
527 actual: LogHash,
528 },
529 InvalidEntry {
530 index: LogIndex,
531 message: String,
532 },
533 ForeignIdentity {
534 index: LogIndex,
535 },
536 InvalidRequest {
537 message: String,
538 },
539}
540
541impl fmt::Display for FetchLogError {
542 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
543 match self {
544 Self::SnapshotRequired { anchor } => {
545 write!(
546 f,
547 "snapshot restore required at qlog anchor {}",
548 anchor.compacted().index()
549 )
550 }
551 Self::Gap { expected, actual } => {
552 write!(f, "qlog gap: expected {expected}, got {actual:?}")
553 }
554 Self::Decode { message } => write!(f, "qlog response decode failed: {message}"),
555 Self::Transport { message } => write!(f, "qlog transport failed: {message}"),
556 Self::InvalidAnchor { .. } => write!(f, "qlog response has an invalid anchor"),
557 Self::InvalidEntry { index, message } => {
558 write!(f, "qlog entry {index} is invalid: {message}")
559 }
560 Self::ForeignIdentity { index } => {
561 write!(f, "qlog entry {index} has a foreign identity")
562 }
563 Self::InvalidRequest { message } => write!(f, "invalid qlog request: {message}"),
564 }
565 }
566}
567
568impl std::error::Error for FetchLogError {}
569
570pub trait LogPeer: Send + Sync {
571 fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError>;
577}
578
579#[derive(Clone, Debug)]
580pub struct StartupIoContext {
581 cancelled: Arc<AtomicBool>,
582 deadline: Arc<OnceLock<Instant>>,
583 stage: Arc<Mutex<&'static str>>,
584 mutation_admission: Arc<Mutex<()>>,
585}
586
587impl Default for StartupIoContext {
588 fn default() -> Self {
589 Self::new()
590 }
591}
592
593impl StartupIoContext {
594 pub fn new() -> Self {
595 Self {
596 cancelled: Arc::new(AtomicBool::new(false)),
597 deadline: Arc::new(OnceLock::new()),
598 stage: Arc::new(Mutex::new("runtime initialization")),
599 mutation_admission: Arc::new(Mutex::new(())),
600 }
601 }
602
603 pub fn cancel(&self, deadline: Instant) {
604 self.cancelled.store(true, Ordering::Release);
605 let _ = self.deadline.set(deadline);
606 }
607
608 pub fn unfinished_stage(&self) -> &'static str {
609 *self.lock_stage()
610 }
611
612 pub fn check(&self, stage: &'static str) -> Result<(), NodeError> {
613 *self.lock_stage() = stage;
614 if self.cancelled.load(Ordering::Acquire)
615 || self
616 .deadline
617 .get()
618 .is_some_and(|deadline| Instant::now() >= *deadline)
619 {
620 return Err(NodeError::Unavailable(format!(
621 "startup cancelled during {stage}"
622 )));
623 }
624 Ok(())
625 }
626
627 fn persist<T>(
628 &self,
629 stage: &'static str,
630 operation: impl FnOnce() -> Result<T, NodeError>,
631 ) -> Result<T, NodeError> {
632 *self.lock_stage() = stage;
633 let _permit = self
634 .mutation_admission
635 .lock()
636 .unwrap_or_else(std::sync::PoisonError::into_inner);
637 if self.cancelled.load(Ordering::Acquire)
638 || self
639 .deadline
640 .get()
641 .is_some_and(|deadline| Instant::now() >= *deadline)
642 {
643 return Err(NodeError::Unavailable(format!(
644 "startup cancelled before {stage}"
645 )));
646 }
647 operation()
648 }
649
650 fn cancellation_flag(&self) -> &AtomicBool {
651 self.cancelled.as_ref()
652 }
653
654 fn lock_stage(&self) -> std::sync::MutexGuard<'_, &'static str> {
655 self.stage
656 .lock()
657 .unwrap_or_else(std::sync::PoisonError::into_inner)
658 }
659}
660
661#[derive(Clone, Debug, Eq, PartialEq)]
662pub struct InMemoryLogPeer {
663 entries: Vec<LogEntry>,
664 anchor: Option<RecoveryAnchor>,
665}
666
667impl InMemoryLogPeer {
668 pub fn new(mut entries: Vec<LogEntry>) -> Self {
669 entries.sort_by_key(|entry| entry.index);
670 Self {
671 entries,
672 anchor: None,
673 }
674 }
675
676 pub fn with_anchor(mut entries: Vec<LogEntry>, anchor: RecoveryAnchor) -> Self {
677 entries.sort_by_key(|entry| entry.index);
678 Self {
679 entries,
680 anchor: Some(anchor),
681 }
682 }
683}
684
685impl LogPeer for InMemoryLogPeer {
686 fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
687 if let Some(anchor) = &self.anchor {
688 if request.from_index <= anchor.compacted().index() {
689 return Err(FetchLogError::SnapshotRequired {
690 anchor: Box::new(anchor.clone()),
691 });
692 }
693 }
694 let entries = self
695 .entries
696 .iter()
697 .filter(|entry| entry.index >= request.from_index)
698 .take(request.max_entries as usize)
699 .cloned()
700 .collect();
701 let last_index = self
702 .entries
703 .last()
704 .map(|entry| entry.index)
705 .or_else(|| {
706 self.anchor
707 .as_ref()
708 .map(|anchor| anchor.compacted().index())
709 })
710 .unwrap_or(0);
711 Ok(FetchLogResponse {
712 entries,
713 last_index,
714 })
715 }
716}
717
718#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
719struct RecorderWire<T> {
720 version: u16,
721 body: T,
722}
723
724#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
725#[serde(tag = "status", content = "body")]
726enum RecorderV2Result<T> {
727 Ok(T),
728 Rejected(RejectReason),
729 Error(String),
730}
731
732#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
733struct StoreCommandV2 {
734 cluster_id: String,
735 epoch: u64,
736 config_id: u64,
737 config_digest: LogHash,
738 command_hash: LogHash,
739 command: StoredCommand,
740}
741
742#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
743struct FetchCommandV2 {
744 cluster_id: String,
745 epoch: u64,
746 config_id: u64,
747 config_digest: LogHash,
748 command_hash: LogHash,
749}
750
751#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
752struct InspectProofV2 {
753 slot: u64,
754}
755
756#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
757struct InstallProofV2 {
758 proof: DecisionProof,
759 members: Vec<String>,
760}
761
762#[derive(Clone, Debug)]
763pub struct HttpRecorderClient {
764 base_url: String,
765 local_node_id: String,
766 peer_token: String,
767 recovery_generation: u64,
768 client: Arc<std::sync::OnceLock<reqwest::blocking::Client>>,
769}
770
771impl HttpRecorderClient {
772 pub fn new(
773 base_url: impl Into<String>,
774 local_node_id: impl Into<String>,
775 peer_token: impl Into<String>,
776 ) -> Result<Self, ConfigError> {
777 Self::new_with_recovery_generation(base_url, local_node_id, peer_token, 1)
778 }
779
780 pub fn new_with_recovery_generation(
781 base_url: impl Into<String>,
782 local_node_id: impl Into<String>,
783 peer_token: impl Into<String>,
784 recovery_generation: u64,
785 ) -> Result<Self, ConfigError> {
786 validate_recovery_generation(recovery_generation)?;
787 let peer = PeerConfig::new(local_node_id, base_url, peer_token)?;
788 Ok(Self {
789 base_url: peer.base_url,
790 local_node_id: peer.node_id,
791 peer_token: peer.token,
792 recovery_generation,
793 client: Arc::new(std::sync::OnceLock::new()),
794 })
795 }
796
797 pub fn with_recovery_generation(
798 mut self,
799 recovery_generation: u64,
800 ) -> Result<Self, ConfigError> {
801 validate_recovery_generation(recovery_generation)?;
802 self.recovery_generation = recovery_generation;
803 Ok(self)
804 }
805
806 fn url(&self, path: &str) -> String {
807 format!("{}{}", self.base_url, path)
808 }
809
810 fn client(&self) -> rhiza_quepaxa::Result<&reqwest::blocking::Client> {
811 if self.client.get().is_none() {
812 let client = reqwest::blocking::Client::builder()
813 .connect_timeout(HTTP_CONNECT_TIMEOUT)
814 .timeout(HTTP_REQUEST_TIMEOUT)
815 .build()
816 .map_err(|error| rhiza_quepaxa::Error::Io(error.to_string()))?;
817 let _ = self.client.set(client);
818 }
819 self.client
820 .get()
821 .ok_or_else(|| rhiza_quepaxa::Error::Io("HTTP client initialization failed".into()))
822 }
823
824 fn post_v2<T, U>(&self, path: &str, body: T) -> rhiza_quepaxa::Result<U>
825 where
826 T: serde::Serialize,
827 U: serde::de::DeserializeOwned,
828 {
829 self.post_v2_with_timeout(path, body, HTTP_REQUEST_TIMEOUT)
830 }
831
832 fn post_v2_with_timeout<T, U>(
833 &self,
834 path: &str,
835 body: T,
836 timeout: Duration,
837 ) -> rhiza_quepaxa::Result<U>
838 where
839 T: serde::Serialize,
840 U: serde::de::DeserializeOwned,
841 {
842 let response = self
843 .client()?
844 .post(self.url(path))
845 .timeout(timeout)
846 .header(VERSION_HEADER, RECORDER_PROTOCOL_VERSION)
847 .header(NODE_ID_HEADER, &self.local_node_id)
848 .header(
849 RECOVERY_GENERATION_HEADER,
850 self.recovery_generation.to_string(),
851 )
852 .bearer_auth(&self.peer_token)
853 .json(&RecorderWire {
854 version: RECORDER_WIRE_VERSION,
855 body,
856 })
857 .send()
858 .map_err(|error| rhiza_quepaxa::Error::Io(error.to_string()))?;
859 let status = response.status();
860 let wire = response
861 .json::<RecorderWire<RecorderV2Result<U>>>()
862 .map_err(|error| rhiza_quepaxa::Error::Decode(error.to_string()))?;
863 if wire.version != RECORDER_WIRE_VERSION {
864 return Err(rhiza_quepaxa::Error::Decode(
865 "recorder wire version mismatch".into(),
866 ));
867 }
868 match wire.body {
869 RecorderV2Result::Ok(value) if status.is_success() => Ok(value),
870 RecorderV2Result::Ok(_) => Err(rhiza_quepaxa::Error::Io(format!(
871 "recorder rpc returned HTTP {status}"
872 ))),
873 RecorderV2Result::Rejected(reason) => Err(rhiza_quepaxa::Error::Rejected(reason)),
874 RecorderV2Result::Error(message) => Err(rhiza_quepaxa::Error::Io(message)),
875 }
876 }
877}
878
879impl RecorderRpc for HttpRecorderClient {
880 fn recorder_id(&self) -> rhiza_quepaxa::Result<String> {
881 self.post_v2(RECORDER_IDENTITY_PATH, ())
882 }
883
884 fn store_command_for(
885 &self,
886 cluster_id: String,
887 epoch: u64,
888 config_id: u64,
889 config_digest: LogHash,
890 command_hash: LogHash,
891 command: StoredCommand,
892 ) -> rhiza_quepaxa::Result<()> {
893 self.post_v2(
894 RECORDER_STORE_COMMAND_PATH,
895 StoreCommandV2 {
896 cluster_id,
897 epoch,
898 config_id,
899 config_digest,
900 command_hash,
901 command,
902 },
903 )
904 }
905
906 fn fetch_command_for(
907 &self,
908 cluster_id: String,
909 epoch: u64,
910 config_id: u64,
911 config_digest: LogHash,
912 command_hash: LogHash,
913 ) -> rhiza_quepaxa::Result<Option<StoredCommand>> {
914 self.post_v2(
915 RECORDER_FETCH_COMMAND_PATH,
916 FetchCommandV2 {
917 cluster_id,
918 epoch,
919 config_id,
920 config_digest,
921 command_hash,
922 },
923 )
924 }
925
926 fn record(&self, request: RecordRequest) -> rhiza_quepaxa::Result<RecordSummary> {
927 self.post_v2_with_timeout(RECORDER_RECORD_PATH, request, QUORUM_RECORD_REQUEST_TIMEOUT)
928 .map_err(map_quorum_record_transport_error)
929 }
930
931 fn install_decision_proof(
932 &self,
933 proof: DecisionProof,
934 membership: &Membership,
935 ) -> rhiza_quepaxa::Result<()> {
936 self.post_v2(
937 RECORDER_INSTALL_PROOF_PATH,
938 InstallProofV2 {
939 proof,
940 members: membership.members().to_vec(),
941 },
942 )
943 }
944
945 fn inspect_decision_proof(&self, slot: u64) -> rhiza_quepaxa::Result<Option<DecisionProof>> {
946 self.post_v2(RECORDER_INSPECT_PROOF_PATH, InspectProofV2 { slot })
947 }
948
949 fn inspect_record_summary(&self, slot: u64) -> rhiza_quepaxa::Result<Option<RecordSummary>> {
950 self.post_v2(RECORDER_INSPECT_RECORD_PATH, InspectProofV2 { slot })
951 }
952
953 fn supports_context_read_fence(&self) -> bool {
954 true
955 }
956
957 fn observe_read_fence(
958 &self,
959 request: ReadFenceRequest,
960 ) -> rhiza_quepaxa::Result<ReadFenceObservation> {
961 self.post_v2_with_timeout(
962 RECORDER_READ_FENCE_PATH,
963 request,
964 READ_FENCE_REQUEST_TIMEOUT,
965 )
966 }
967}
968
969#[derive(Clone, Debug)]
970pub struct HttpLogPeer {
971 base_url: String,
972 local_node_id: String,
973 peer_token: String,
974 recovery_generation: u64,
975 client: Arc<std::sync::OnceLock<reqwest::blocking::Client>>,
976}
977
978impl HttpLogPeer {
979 pub fn new(
980 base_url: impl Into<String>,
981 local_node_id: impl Into<String>,
982 peer_token: impl Into<String>,
983 ) -> Result<Self, ConfigError> {
984 Self::new_with_recovery_generation(base_url, local_node_id, peer_token, 1)
985 }
986
987 pub fn new_with_recovery_generation(
988 base_url: impl Into<String>,
989 local_node_id: impl Into<String>,
990 peer_token: impl Into<String>,
991 recovery_generation: u64,
992 ) -> Result<Self, ConfigError> {
993 validate_recovery_generation(recovery_generation)?;
994 let peer = PeerConfig::new(local_node_id, base_url, peer_token)?;
995 Ok(Self {
996 base_url: peer.base_url,
997 local_node_id: peer.node_id,
998 peer_token: peer.token,
999 recovery_generation,
1000 client: Arc::new(std::sync::OnceLock::new()),
1001 })
1002 }
1003
1004 pub fn with_recovery_generation(
1005 mut self,
1006 recovery_generation: u64,
1007 ) -> Result<Self, ConfigError> {
1008 validate_recovery_generation(recovery_generation)?;
1009 self.recovery_generation = recovery_generation;
1010 Ok(self)
1011 }
1012
1013 fn url(&self, path: &str) -> String {
1014 format!("{}{}", self.base_url, path)
1015 }
1016
1017 fn client(&self) -> Result<&reqwest::blocking::Client, FetchLogError> {
1018 if self.client.get().is_none() {
1019 let client = reqwest::blocking::Client::builder()
1020 .connect_timeout(HTTP_CONNECT_TIMEOUT)
1021 .timeout(HTTP_REQUEST_TIMEOUT)
1022 .build()
1023 .map_err(|error| FetchLogError::Transport {
1024 message: error.to_string(),
1025 })?;
1026 let _ = self.client.set(client);
1027 }
1028 self.client.get().ok_or_else(|| FetchLogError::Transport {
1029 message: "HTTP client initialization failed".into(),
1030 })
1031 }
1032}
1033
1034impl LogPeer for HttpLogPeer {
1035 fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
1036 let response = self
1037 .client()?
1038 .post(self.url(LOG_FETCH_PATH))
1039 .header(VERSION_HEADER, PROTOCOL_VERSION)
1040 .header(NODE_ID_HEADER, &self.local_node_id)
1041 .header(
1042 RECOVERY_GENERATION_HEADER,
1043 self.recovery_generation.to_string(),
1044 )
1045 .bearer_auth(&self.peer_token)
1046 .json(&request)
1047 .send()
1048 .map_err(|err| FetchLogError::Transport {
1049 message: err.to_string(),
1050 })?;
1051 let status = response.status();
1052 if !status.is_success() {
1053 return match response.json::<FetchLogHttpResponse>() {
1054 Ok(FetchLogHttpResponse::Failed(error)) => Err(error),
1055 Ok(FetchLogHttpResponse::Fetched(_)) | Err(_) => Err(FetchLogError::Transport {
1056 message: format!("log rpc returned HTTP {status}"),
1057 }),
1058 };
1059 }
1060 match response
1061 .json::<FetchLogHttpResponse>()
1062 .map_err(|err| FetchLogError::Decode {
1063 message: err.to_string(),
1064 })? {
1065 FetchLogHttpResponse::Fetched(response) => Ok(response),
1066 FetchLogHttpResponse::Failed(error) => Err(error),
1067 }
1068 }
1069}
1070
1071#[derive(Clone)]
1072struct RecorderRouteState<R> {
1073 recorder: R,
1074 peers: Vec<PeerConfig>,
1075}
1076
1077#[derive(Clone)]
1078struct AuthenticatedPeer(String);
1079
1080#[derive(Clone)]
1081struct LogRouteState<P> {
1082 peer: P,
1083}
1084
1085#[derive(Clone)]
1086struct NodeRouteState {
1087 runtime: Arc<NodeRuntime>,
1088 coordinator: Option<Arc<CheckpointCoordinator>>,
1089 write_operations: Arc<tokio::sync::Mutex<HashMap<String, WriteOperation>>>,
1090 writer: tokio::sync::mpsc::Sender<QueuedWrite>,
1091}
1092
1093#[derive(Clone)]
1094struct WriteOperation {
1095 payload: Vec<u8>,
1096 result: tokio::sync::watch::Receiver<Option<WriteOperationResult>>,
1097}
1098
1099#[derive(Clone)]
1100enum WriteOperationResult {
1101 Runtime(Result<ClientWriteResponse, NodeError>),
1102 DurabilityUnavailable,
1103}
1104
1105#[derive(Clone)]
1106enum ClientWriteResponse {
1107 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1108 Unavailable,
1109 #[cfg(feature = "sql")]
1110 KeyValue(WriteResponse),
1111 #[cfg(feature = "sql")]
1112 Sql(SqlExecuteResponse),
1113 #[cfg(feature = "graph")]
1114 Graph(GraphMutationOutcome),
1115 #[cfg(feature = "kv")]
1116 Kv(KvMutationOutcome),
1117}
1118
1119struct QueuedWrite {
1120 request_id: String,
1121 payload: Vec<u8>,
1122 operation: QueuedOperation,
1123 permit: Arc<tokio::sync::OwnedSemaphorePermit>,
1124 sender: tokio::sync::watch::Sender<Option<WriteOperationResult>>,
1125}
1126
1127enum QueuedOperation {
1128 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1129 Unavailable,
1130 #[cfg(feature = "sql")]
1131 KeyValue { key: String, value: String },
1132 #[cfg(feature = "sql")]
1133 Sql(SqlCommand),
1134 #[cfg(feature = "graph")]
1135 Graph(GraphCommandV1),
1136 #[cfg(feature = "kv")]
1137 Kv(KvCommandV1),
1138}
1139
1140struct RuntimeBatchMember {
1141 #[cfg(feature = "sql")]
1142 request_id: String,
1143 payload: Vec<u8>,
1144 operation: QueuedOperation,
1145}
1146
1147#[cfg(feature = "sql")]
1148type SqlGroupCommitResult = Result<Vec<Result<ClientWriteResponse, NodeError>>, NodeError>;
1149
1150#[cfg(feature = "sql")]
1151struct SqlGroupCommitJob {
1152 member_count: usize,
1153 encoded_bytes: usize,
1154 members: Mutex<Option<Vec<RuntimeBatchMember>>>,
1155 result: Mutex<Option<SqlGroupCommitResult>>,
1156 changed: Condvar,
1157}
1158
1159#[cfg(feature = "sql")]
1160impl SqlGroupCommitJob {
1161 fn new(members: Vec<RuntimeBatchMember>) -> Self {
1162 Self {
1163 member_count: members.len(),
1164 encoded_bytes: members.iter().fold(0_usize, |bytes, member| {
1165 bytes.saturating_add(member.payload.len())
1166 }),
1167 members: Mutex::new(Some(members)),
1168 result: Mutex::new(None),
1169 changed: Condvar::new(),
1170 }
1171 }
1172
1173 fn take_members(&self) -> Result<Vec<RuntimeBatchMember>, NodeError> {
1174 self.members
1175 .lock()
1176 .unwrap_or_else(std::sync::PoisonError::into_inner)
1177 .take()
1178 .ok_or_else(|| {
1179 NodeError::Invariant("SQL group commit job members were already consumed".into())
1180 })
1181 }
1182
1183 fn publish(&self, result: SqlGroupCommitResult) {
1184 let mut slot = self
1185 .result
1186 .lock()
1187 .unwrap_or_else(std::sync::PoisonError::into_inner);
1188 if slot.is_none() {
1189 *slot = Some(result);
1190 self.changed.notify_all();
1191 }
1192 }
1193
1194 fn wait(&self, cancelled: &AtomicBool) -> SqlGroupCommitResult {
1195 let mut result = self
1196 .result
1197 .lock()
1198 .unwrap_or_else(std::sync::PoisonError::into_inner);
1199 loop {
1200 if let Some(result) = result.take() {
1201 return result;
1202 }
1203 if cancelled.load(Ordering::Acquire) {
1204 return Err(NodeError::Unavailable(
1205 "SQL group commit cancelled during shutdown".into(),
1206 ));
1207 }
1208 result = self
1209 .changed
1210 .wait(result)
1211 .unwrap_or_else(std::sync::PoisonError::into_inner);
1212 }
1213 }
1214}
1215
1216#[cfg(feature = "sql")]
1217struct SqlGroupCommitQueue {
1218 capacity: usize,
1219 state: Mutex<SqlGroupCommitQueueState>,
1220 changed: Condvar,
1221}
1222
1223#[cfg(feature = "sql")]
1224#[derive(Default)]
1225struct SqlGroupCommitQueueState {
1226 pending: VecDeque<Arc<SqlGroupCommitJob>>,
1227 pending_encoded_bytes: usize,
1228 leader_active: bool,
1229}
1230
1231#[cfg(feature = "sql")]
1232impl SqlGroupCommitQueue {
1233 fn new(capacity: usize) -> Self {
1234 Self {
1235 capacity,
1236 state: Mutex::new(SqlGroupCommitQueueState::default()),
1237 changed: Condvar::new(),
1238 }
1239 }
1240
1241 fn enqueue(
1242 &self,
1243 members: Vec<RuntimeBatchMember>,
1244 cancelled: &AtomicBool,
1245 ) -> Result<(Arc<SqlGroupCommitJob>, bool), NodeError> {
1246 if cancelled.load(Ordering::Acquire) {
1247 return Err(NodeError::Unavailable(
1248 "SQL group commit is unavailable during shutdown".into(),
1249 ));
1250 }
1251 let job = Arc::new(SqlGroupCommitJob::new(members));
1252 if job.encoded_bytes > MAX_COMMAND_BYTES {
1253 return Err(NodeError::ResourceExhausted(format!(
1254 "SQL group commit call exceeds {MAX_COMMAND_BYTES} encoded bytes"
1255 )));
1256 }
1257 let mut state = self
1258 .state
1259 .lock()
1260 .unwrap_or_else(std::sync::PoisonError::into_inner);
1261 if state.pending.len() >= self.capacity {
1262 return Err(NodeError::ResourceExhausted(format!(
1263 "SQL group commit queue is full (capacity {})",
1264 self.capacity
1265 )));
1266 }
1267 let pending_encoded_bytes = state
1268 .pending_encoded_bytes
1269 .saturating_add(job.encoded_bytes);
1270 if pending_encoded_bytes > MAX_SQL_GROUP_COMMIT_PENDING_BYTES {
1271 return Err(NodeError::ResourceExhausted(format!(
1272 "SQL group commit queue exceeds {MAX_SQL_GROUP_COMMIT_PENDING_BYTES} pending encoded bytes"
1273 )));
1274 }
1275 state.pending_encoded_bytes = pending_encoded_bytes;
1276 state.pending.push_back(Arc::clone(&job));
1277 self.changed.notify_all();
1278 let leader = !state.leader_active;
1279 if leader {
1280 state.leader_active = true;
1281 }
1282 Ok((job, leader))
1283 }
1284
1285 fn drain_next_group(&self) -> Option<Vec<Arc<SqlGroupCommitJob>>> {
1286 let mut state = self
1287 .state
1288 .lock()
1289 .unwrap_or_else(std::sync::PoisonError::into_inner);
1290 if state.pending.is_empty() {
1291 state.leader_active = false;
1292 return None;
1293 }
1294 let mut member_count = 0_usize;
1295 let mut encoded_bytes = 0_usize;
1296 let mut jobs = Vec::new();
1297 while let Some(job) = state.pending.front() {
1298 let next_count = member_count.saturating_add(job.member_count);
1299 let next_encoded_bytes = encoded_bytes.saturating_add(job.encoded_bytes);
1300 if !jobs.is_empty()
1301 && (next_count > MAX_SQL_WRITE_BATCH_MEMBERS
1302 || next_encoded_bytes > MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES)
1303 {
1304 break;
1305 }
1306 let job = state.pending.pop_front().expect("front job exists");
1307 member_count = next_count;
1308 encoded_bytes = next_encoded_bytes;
1309 state.pending_encoded_bytes = state
1310 .pending_encoded_bytes
1311 .checked_sub(job.encoded_bytes)
1312 .expect("queued SQL byte reservation covers every pending job");
1313 jobs.push(job);
1314 }
1315 Some(jobs)
1316 }
1317
1318 fn collect_until_full_or_timeout(&self, timeout: Duration) -> bool {
1319 let mut state = self
1320 .state
1321 .lock()
1322 .unwrap_or_else(std::sync::PoisonError::into_inner);
1323 if state.pending.is_empty() {
1324 state.leader_active = false;
1325 return false;
1326 }
1327 let hard_deadline = Instant::now() + timeout.saturating_mul(4);
1328 loop {
1329 let member_count = state
1330 .pending
1331 .iter()
1332 .fold(0_usize, |count, job| count.saturating_add(job.member_count));
1333 if member_count >= MAX_SQL_WRITE_BATCH_MEMBERS
1334 || state.pending_encoded_bytes >= MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES
1335 {
1336 return true;
1337 }
1338 let remaining = hard_deadline.saturating_duration_since(Instant::now());
1339 if remaining.is_zero() {
1340 return true;
1341 }
1342 let observed_calls = state.pending.len();
1343 let (next, quiet) = self
1344 .changed
1345 .wait_timeout_while(state, timeout.min(remaining), |state| {
1346 state.pending.len() == observed_calls
1347 && state
1348 .pending
1349 .iter()
1350 .fold(0_usize, |count, job| count.saturating_add(job.member_count))
1351 < MAX_SQL_WRITE_BATCH_MEMBERS
1352 && state.pending_encoded_bytes < MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES
1353 })
1354 .unwrap_or_else(std::sync::PoisonError::into_inner);
1355 state = next;
1356 if quiet.timed_out() {
1357 return true;
1358 }
1359 }
1360 }
1361
1362 fn fail_pending(&self, error: NodeError) {
1363 let jobs = {
1364 let mut state = self
1365 .state
1366 .lock()
1367 .unwrap_or_else(std::sync::PoisonError::into_inner);
1368 state.leader_active = false;
1369 state.pending_encoded_bytes = 0;
1370 state.pending.drain(..).collect::<Vec<_>>()
1371 };
1372 for job in jobs {
1373 job.publish(Err(error.clone()));
1374 }
1375 }
1376
1377 #[cfg(test)]
1378 fn wait_for_pending_calls(&self, expected: usize, timeout: Duration) {
1379 let state = self
1380 .state
1381 .lock()
1382 .unwrap_or_else(std::sync::PoisonError::into_inner);
1383 let (state, timed_out) = self
1384 .changed
1385 .wait_timeout_while(state, timeout, |state| state.pending.len() != expected)
1386 .unwrap_or_else(std::sync::PoisonError::into_inner);
1387 assert!(
1388 !timed_out.timed_out(),
1389 "expected {expected} pending SQL group commit calls, got {}",
1390 state.pending.len()
1391 );
1392 }
1393}
1394
1395#[cfg(feature = "kv")]
1396type KvGroupCommitResult = Result<Vec<Result<ClientWriteResponse, NodeError>>, NodeError>;
1397
1398#[cfg(feature = "kv")]
1399struct KvGroupCommitJob {
1400 member_count: usize,
1401 encoded_bytes: usize,
1402 members: Mutex<Option<Vec<RuntimeBatchMember>>>,
1403 result: Mutex<Option<KvGroupCommitResult>>,
1404 changed: Condvar,
1405}
1406
1407#[cfg(feature = "kv")]
1408impl KvGroupCommitJob {
1409 fn new(members: Vec<RuntimeBatchMember>) -> Self {
1410 Self {
1411 member_count: members.len(),
1412 encoded_bytes: members.iter().fold(0_usize, |bytes, member| {
1413 bytes.saturating_add(member.payload.len())
1414 }),
1415 members: Mutex::new(Some(members)),
1416 result: Mutex::new(None),
1417 changed: Condvar::new(),
1418 }
1419 }
1420
1421 fn take_members(&self) -> Result<Vec<RuntimeBatchMember>, NodeError> {
1422 self.members
1423 .lock()
1424 .unwrap_or_else(std::sync::PoisonError::into_inner)
1425 .take()
1426 .ok_or_else(|| {
1427 NodeError::Invariant("KV group commit job members were already consumed".into())
1428 })
1429 }
1430
1431 fn publish(&self, result: KvGroupCommitResult) {
1432 let mut slot = self
1433 .result
1434 .lock()
1435 .unwrap_or_else(std::sync::PoisonError::into_inner);
1436 if slot.is_none() {
1437 *slot = Some(result);
1438 self.changed.notify_all();
1439 }
1440 }
1441
1442 fn wait(&self, cancelled: &AtomicBool) -> KvGroupCommitResult {
1443 let mut result = self
1444 .result
1445 .lock()
1446 .unwrap_or_else(std::sync::PoisonError::into_inner);
1447 loop {
1448 if let Some(result) = result.take() {
1449 return result;
1450 }
1451 if cancelled.load(Ordering::Acquire) {
1452 return Err(NodeError::Unavailable(
1453 "KV group commit cancelled during shutdown".into(),
1454 ));
1455 }
1456 result = self
1457 .changed
1458 .wait(result)
1459 .unwrap_or_else(std::sync::PoisonError::into_inner);
1460 }
1461 }
1462}
1463
1464#[cfg(feature = "kv")]
1465struct KvGroupCommitQueue {
1466 state: Mutex<KvGroupCommitQueueState>,
1467 changed: Condvar,
1468}
1469
1470#[cfg(feature = "kv")]
1471#[derive(Default)]
1472struct KvGroupCommitQueueState {
1473 pending: VecDeque<Arc<KvGroupCommitJob>>,
1474 pending_encoded_bytes: usize,
1475 leader_active: bool,
1476}
1477
1478#[cfg(feature = "kv")]
1479impl KvGroupCommitQueue {
1480 fn new() -> Self {
1481 Self {
1482 state: Mutex::new(KvGroupCommitQueueState::default()),
1483 changed: Condvar::new(),
1484 }
1485 }
1486
1487 fn enqueue(
1488 &self,
1489 members: Vec<RuntimeBatchMember>,
1490 cancelled: &AtomicBool,
1491 ) -> Result<(Arc<KvGroupCommitJob>, bool), NodeError> {
1492 if cancelled.load(Ordering::Acquire) {
1493 return Err(NodeError::Unavailable(
1494 "KV group commit is unavailable during shutdown".into(),
1495 ));
1496 }
1497 let job = Arc::new(KvGroupCommitJob::new(members));
1498 if job.member_count == 0 || job.member_count > MAX_KV_BATCH_MEMBERS {
1499 return Err(NodeError::InvalidRequest(format!(
1500 "KV group commit call must contain 1..={MAX_KV_BATCH_MEMBERS} members"
1501 )));
1502 }
1503 if job.encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES {
1504 return Err(NodeError::ResourceExhausted(format!(
1505 "KV group commit call exceeds {MAX_KV_GROUP_COMMIT_PENDING_BYTES} encoded bytes"
1506 )));
1507 }
1508 let mut state = self
1509 .state
1510 .lock()
1511 .unwrap_or_else(std::sync::PoisonError::into_inner);
1512 if state.pending.len() >= KV_GROUP_COMMIT_QUEUE_CAPACITY {
1513 return Err(NodeError::ResourceExhausted(format!(
1514 "KV group commit queue is full (capacity {KV_GROUP_COMMIT_QUEUE_CAPACITY})"
1515 )));
1516 }
1517 let pending_encoded_bytes = state
1518 .pending_encoded_bytes
1519 .saturating_add(job.encoded_bytes);
1520 if pending_encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES {
1521 return Err(NodeError::ResourceExhausted(format!(
1522 "KV group commit queue exceeds {MAX_KV_GROUP_COMMIT_PENDING_BYTES} pending encoded bytes"
1523 )));
1524 }
1525 state.pending_encoded_bytes = pending_encoded_bytes;
1526 state.pending.push_back(Arc::clone(&job));
1527 self.changed.notify_all();
1528 let leader = !state.leader_active;
1529 if leader {
1530 state.leader_active = true;
1531 }
1532 Ok((job, leader))
1533 }
1534
1535 fn drain_next_group(&self) -> Option<Vec<Arc<KvGroupCommitJob>>> {
1536 let mut state = self
1537 .state
1538 .lock()
1539 .unwrap_or_else(std::sync::PoisonError::into_inner);
1540 if state.pending.is_empty() {
1541 state.leader_active = false;
1542 return None;
1543 }
1544 let mut member_count = 0_usize;
1545 let mut encoded_bytes = 0_usize;
1546 let mut jobs = Vec::new();
1547 while let Some(job) = state.pending.front() {
1548 let next_count = member_count.saturating_add(job.member_count);
1549 let next_encoded_bytes = encoded_bytes.saturating_add(job.encoded_bytes);
1550 if !jobs.is_empty()
1551 && (next_count > MAX_KV_GROUP_COMMIT_MEMBERS
1552 || next_encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES)
1553 {
1554 break;
1555 }
1556 let job = state.pending.pop_front().expect("front job exists");
1557 member_count = next_count;
1558 encoded_bytes = next_encoded_bytes;
1559 state.pending_encoded_bytes = state
1560 .pending_encoded_bytes
1561 .checked_sub(job.encoded_bytes)
1562 .expect("queued KV byte reservation covers every pending job");
1563 jobs.push(job);
1564 }
1565 Some(jobs)
1566 }
1567
1568 fn collect_until_full_or_timeout(&self, timeout: Duration) -> bool {
1569 let mut state = self
1570 .state
1571 .lock()
1572 .unwrap_or_else(std::sync::PoisonError::into_inner);
1573 if state.pending.is_empty() {
1574 state.leader_active = false;
1575 return false;
1576 }
1577 let hard_deadline = Instant::now() + timeout.saturating_mul(4);
1578 loop {
1579 let member_count = state
1580 .pending
1581 .iter()
1582 .fold(0_usize, |count, job| count.saturating_add(job.member_count));
1583 if member_count >= MAX_KV_GROUP_COMMIT_MEMBERS
1584 || state.pending_encoded_bytes >= MAX_KV_GROUP_COMMIT_PENDING_BYTES
1585 {
1586 return true;
1587 }
1588 let remaining = hard_deadline.saturating_duration_since(Instant::now());
1589 if remaining.is_zero() {
1590 return true;
1591 }
1592 let observed_calls = state.pending.len();
1593 let (next, quiet) = self
1594 .changed
1595 .wait_timeout_while(state, timeout.min(remaining), |state| {
1596 state.pending.len() == observed_calls
1597 && state
1598 .pending
1599 .iter()
1600 .fold(0_usize, |count, job| count.saturating_add(job.member_count))
1601 < MAX_KV_GROUP_COMMIT_MEMBERS
1602 && state.pending_encoded_bytes < MAX_KV_GROUP_COMMIT_PENDING_BYTES
1603 })
1604 .unwrap_or_else(std::sync::PoisonError::into_inner);
1605 state = next;
1606 if quiet.timed_out() {
1607 return true;
1608 }
1609 }
1610 }
1611
1612 fn fail_pending(&self, error: NodeError) {
1613 let jobs = {
1614 let mut state = self
1615 .state
1616 .lock()
1617 .unwrap_or_else(std::sync::PoisonError::into_inner);
1618 state.leader_active = false;
1619 state.pending_encoded_bytes = 0;
1620 state.pending.drain(..).collect::<Vec<_>>()
1621 };
1622 for job in jobs {
1623 job.publish(Err(error.clone()));
1624 }
1625 }
1626
1627 #[cfg(test)]
1628 fn wait_for_pending_calls(&self, expected: usize, timeout: Duration) {
1629 let state = self
1630 .state
1631 .lock()
1632 .unwrap_or_else(std::sync::PoisonError::into_inner);
1633 let (state, timed_out) = self
1634 .changed
1635 .wait_timeout_while(state, timeout, |state| state.pending.len() != expected)
1636 .unwrap_or_else(std::sync::PoisonError::into_inner);
1637 assert!(
1638 !timed_out.timed_out(),
1639 "expected {expected} pending KV group commit calls, got {}",
1640 state.pending.len()
1641 );
1642 }
1643}
1644
1645#[cfg(any(feature = "graph", feature = "kv"))]
1646fn classify_pending_request(
1647 canonical_by_request: &mut HashMap<String, usize>,
1648 members: &[RuntimeBatchMember],
1649 index: usize,
1650 request_id: &str,
1651) -> Result<Option<usize>, NodeError> {
1652 let Some(&canonical) = canonical_by_request.get(request_id) else {
1653 canonical_by_request.insert(request_id.to_owned(), index);
1654 return Ok(None);
1655 };
1656 if members[canonical].payload == members[index].payload {
1657 Ok(Some(canonical))
1658 } else {
1659 Err(NodeError::InvalidRequest(format!(
1660 "request id {request_id:?} was reused with another command in the same writer batch"
1661 )))
1662 }
1663}
1664
1665impl ClientWriteResponse {
1666 fn applied_index(&self) -> LogIndex {
1667 match self {
1668 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1669 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
1670 #[cfg(feature = "sql")]
1671 Self::KeyValue(response) => response.applied_index,
1672 #[cfg(feature = "sql")]
1673 Self::Sql(response) => response.applied_index,
1674 #[cfg(feature = "graph")]
1675 Self::Graph(response) => response.applied_index(),
1676 #[cfg(feature = "kv")]
1677 Self::Kv(response) => response.applied_index(),
1678 }
1679 }
1680}
1681
1682#[cfg(feature = "sql")]
1683#[derive(Clone)]
1684pub struct NodeService {
1685 runtime: Arc<NodeRuntime>,
1686 coordinator: Option<Arc<CheckpointCoordinator>>,
1687 #[cfg(feature = "sql")]
1688 sql_reads_in_flight: Arc<AtomicUsize>,
1689}
1690
1691#[cfg(feature = "sql")]
1692struct SqlReadActivity {
1693 active: Arc<AtomicUsize>,
1694}
1695
1696#[cfg(feature = "sql")]
1697impl SqlReadActivity {
1698 fn enter(active: &Arc<AtomicUsize>) -> (Self, bool) {
1699 let previous = active.fetch_add(1, Ordering::Relaxed);
1700 debug_assert_ne!(previous, usize::MAX);
1701 (
1702 Self {
1703 active: active.clone(),
1704 },
1705 previous == 0,
1706 )
1707 }
1708}
1709
1710#[cfg(feature = "sql")]
1711impl Drop for SqlReadActivity {
1712 fn drop(&mut self) {
1713 let previous = self.active.fetch_sub(1, Ordering::Relaxed);
1714 debug_assert!(previous > 0);
1715 }
1716}
1717
1718#[cfg(feature = "sql")]
1719impl NodeService {
1720 pub fn new(runtime: Arc<NodeRuntime>, coordinator: Option<Arc<CheckpointCoordinator>>) -> Self {
1721 Self {
1722 runtime,
1723 coordinator,
1724 #[cfg(feature = "sql")]
1725 sql_reads_in_flight: Arc::new(AtomicUsize::new(0)),
1726 }
1727 }
1728
1729 #[cfg(feature = "sql")]
1730 pub async fn put(
1731 &self,
1732 request_id: &str,
1733 key: &str,
1734 value: &str,
1735 ) -> Result<WriteResponse, NodeError> {
1736 self.write(WriteRequest {
1737 request_id: request_id.into(),
1738 key: key.into(),
1739 value: value.into(),
1740 })
1741 .await
1742 }
1743
1744 #[cfg(feature = "sql")]
1745 pub async fn write(&self, request: WriteRequest) -> Result<WriteResponse, NodeError> {
1746 self.write_allowed()?;
1747 let runtime = self.runtime.clone();
1748 let response = tokio::task::spawn_blocking(move || {
1749 runtime.write(&request.request_id, &request.key, &request.value)
1750 })
1751 .await
1752 .map_err(node_service_task_error)??;
1753 self.confirm_committed(response.applied_index).await?;
1754 Ok(response)
1755 }
1756
1757 #[cfg(feature = "sql")]
1758 pub async fn execute_sql(&self, command: SqlCommand) -> Result<SqlExecuteResponse, NodeError> {
1759 self.write_allowed()?;
1760 let runtime = self.runtime.clone();
1761 let response =
1762 tokio::task::spawn_blocking(move || runtime.execute_sql_with_results(command))
1763 .await
1764 .map_err(node_service_task_error)??;
1765 self.confirm_committed(response.applied_index).await?;
1766 Ok(response)
1767 }
1768
1769 #[cfg(feature = "sql")]
1770 pub async fn read(
1771 &self,
1772 key: &str,
1773 consistency: ReadConsistency,
1774 ) -> Result<ReadResponse, NodeError> {
1775 let runtime = self.runtime.clone();
1776 let key = key.to_owned();
1777 self.run_sql_read_operation(consistency, move || runtime.read(&key, consistency))
1778 .await
1779 .map_err(node_service_task_error)?
1780 }
1781
1782 #[cfg(feature = "sql")]
1783 pub async fn query(
1784 &self,
1785 statement: SqlStatement,
1786 consistency: ReadConsistency,
1787 max_rows: u32,
1788 ) -> Result<SqlQueryResponse, NodeError> {
1789 let runtime = self.runtime.clone();
1790 self.run_sql_read_operation(consistency, move || {
1791 runtime.query_sql(&statement, consistency, max_rows)
1792 })
1793 .await
1794 .map_err(node_service_task_error)?
1795 }
1796
1797 #[cfg(feature = "sql")]
1798 async fn run_sql_read_operation<F, T>(
1799 &self,
1800 consistency: ReadConsistency,
1801 operation: F,
1802 ) -> Result<T, tokio::task::JoinError>
1803 where
1804 F: FnOnce() -> T + Send + 'static,
1805 T: Send + 'static,
1806 {
1807 if consistency == ReadConsistency::ReadBarrier {
1808 return run_read_operation(consistency, operation).await;
1809 }
1810 let (activity, sole_read) = SqlReadActivity::enter(&self.sql_reads_in_flight);
1811 let operation = move || {
1812 let _activity = activity;
1813 operation()
1814 };
1815 if sole_read {
1816 run_read_operation(consistency, operation).await
1817 } else {
1818 tokio::task::spawn_blocking(operation).await
1819 }
1820 }
1821
1822 #[cfg(feature = "sql")]
1823 fn write_allowed(&self) -> Result<(), NodeError> {
1824 self.coordinator
1825 .as_ref()
1826 .map_or(Ok(()), |coordinator| coordinator.write_allowed())
1827 .map_err(|error| NodeError::Unavailable(error.to_string()))
1828 }
1829
1830 #[cfg(feature = "sql")]
1831 async fn confirm_committed(&self, index: LogIndex) -> Result<(), NodeError> {
1832 confirm_write_durability(self.runtime.as_ref(), self.coordinator.as_deref(), index)
1833 .await
1834 .map_err(|error| NodeError::Unavailable(error.to_string()))
1835 }
1836}
1837
1838#[cfg(feature = "sql")]
1839fn node_service_task_error(error: tokio::task::JoinError) -> NodeError {
1840 NodeError::Fatal(format!("node service task failed: {error}"))
1841}
1842
1843#[doc(hidden)]
1844pub async fn run_read_operation<F, T>(
1845 consistency: ReadConsistency,
1846 operation: F,
1847) -> Result<T, tokio::task::JoinError>
1848where
1849 F: FnOnce() -> T + Send + 'static,
1850 T: Send + 'static,
1851{
1852 if consistency != ReadConsistency::ReadBarrier
1853 && matches!(
1854 tokio::runtime::Handle::current().runtime_flavor(),
1855 tokio::runtime::RuntimeFlavor::MultiThread
1856 )
1857 {
1858 Ok(tokio::task::block_in_place(operation))
1859 } else {
1860 tokio::task::spawn_blocking(operation).await
1861 }
1862}
1863
1864#[derive(Clone)]
1865struct PeerGateState {
1866 peers: Vec<PeerConfig>,
1867 recovery_generation: u64,
1868 protocol_version: &'static str,
1869 slots: Arc<tokio::sync::Semaphore>,
1870}
1871
1872#[derive(Clone)]
1873struct ClientGateState {
1874 runtime: Arc<NodeRuntime>,
1875 slots: Arc<tokio::sync::Semaphore>,
1876 coordinator: Option<Arc<CheckpointCoordinator>>,
1877}
1878
1879#[derive(Clone)]
1880struct RuntimeLogPeer {
1881 runtime: Arc<NodeRuntime>,
1882}
1883
1884impl LogPeer for RuntimeLogPeer {
1885 fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
1886 self.runtime.fetch_log(request)
1887 }
1888}
1889
1890fn fetch_runtime_log(
1891 runtime: &NodeRuntime,
1892 request: FetchLogRequest,
1893) -> Result<FetchLogResponse, FetchLogError> {
1894 if request.from_index == 0 || request.max_entries > MAX_FETCH_ENTRIES {
1895 return Err(FetchLogError::InvalidRequest {
1896 message: "invalid fetch bounds".into(),
1897 });
1898 }
1899 let state = runtime
1900 .log_store
1901 .logical_state()
1902 .map_err(|error| FetchLogError::Transport {
1903 message: error.to_string(),
1904 })?;
1905 if let Some(anchor) = state.anchor {
1906 if request.from_index <= anchor.compacted().index() {
1907 return Err(FetchLogError::SnapshotRequired {
1908 anchor: Box::new(anchor),
1909 });
1910 }
1911 }
1912 let last_index = state.tip.map_or(0, |tip| tip.index());
1913 if request.max_entries == 0 || request.from_index > last_index {
1914 return Ok(FetchLogResponse {
1915 entries: Vec::new(),
1916 last_index,
1917 });
1918 }
1919 let end = request
1920 .from_index
1921 .saturating_add(u64::from(request.max_entries) - 1)
1922 .min(last_index);
1923 let range = IndexRange::new(request.from_index, end).map_err(|error| {
1924 FetchLogError::InvalidRequest {
1925 message: error.to_string(),
1926 }
1927 })?;
1928 let entries =
1929 runtime
1930 .log_store
1931 .read_range(range)
1932 .map_err(|error| FetchLogError::Transport {
1933 message: error.to_string(),
1934 })?;
1935 Ok(FetchLogResponse {
1936 entries,
1937 last_index,
1938 })
1939}
1940
1941pub fn recorder_router<R, P>(recorder: R, peers: P) -> Router
1942where
1943 R: RecorderRpc + Clone + Send + Sync + 'static,
1944 P: Into<Vec<PeerConfig>>,
1945{
1946 recorder_router_for_generation(recorder, peers, 1)
1947}
1948
1949pub fn recorder_router_for_generation<R, P>(
1950 recorder: R,
1951 peers: P,
1952 recovery_generation: u64,
1953) -> Router
1954where
1955 R: RecorderRpc + Clone + Send + Sync + 'static,
1956 P: Into<Vec<PeerConfig>>,
1957{
1958 recorder_routes(
1959 recorder,
1960 peers.into(),
1961 recovery_generation,
1962 Arc::new(tokio::sync::Semaphore::new(DEFAULT_PEER_CONCURRENCY)),
1963 )
1964 .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
1965}
1966
1967pub fn log_peer_router<P, C>(peer: P, peers: C) -> Router
1968where
1969 P: LogPeer + Clone + Send + Sync + 'static,
1970 C: Into<Vec<PeerConfig>>,
1971{
1972 log_peer_router_for_generation(peer, peers, 1)
1973}
1974
1975pub fn log_peer_router_for_generation<P, C>(peer: P, peers: C, recovery_generation: u64) -> Router
1976where
1977 P: LogPeer + Clone + Send + Sync + 'static,
1978 C: Into<Vec<PeerConfig>>,
1979{
1980 log_routes(
1981 peer,
1982 peers.into(),
1983 recovery_generation,
1984 Arc::new(tokio::sync::Semaphore::new(DEFAULT_PEER_CONCURRENCY)),
1985 )
1986 .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
1987}
1988
1989pub fn node_rpc_router<R, P, C>(recorder: R, peer: P, peers: C) -> Router
1990where
1991 R: RecorderRpc + Clone + Send + Sync + 'static,
1992 P: LogPeer + Clone + Send + Sync + 'static,
1993 C: Into<Vec<PeerConfig>>,
1994{
1995 node_rpc_router_for_generation(recorder, peer, peers, 1)
1996}
1997
1998pub fn node_rpc_router_for_generation<R, P, C>(
1999 recorder: R,
2000 peer: P,
2001 peers: C,
2002 recovery_generation: u64,
2003) -> Router
2004where
2005 R: RecorderRpc + Clone + Send + Sync + 'static,
2006 P: LogPeer + Clone + Send + Sync + 'static,
2007 C: Into<Vec<PeerConfig>>,
2008{
2009 node_rpc_router_with_limits_for_generation(
2010 recorder,
2011 peer,
2012 peers,
2013 DEFAULT_PEER_CONCURRENCY,
2014 DEFAULT_PEER_CONCURRENCY,
2015 recovery_generation,
2016 )
2017}
2018
2019pub fn node_rpc_router_with_limits<R, P, C>(
2020 recorder: R,
2021 peer: P,
2022 peers: C,
2023 recorder_concurrency: usize,
2024 log_concurrency: usize,
2025) -> Router
2026where
2027 R: RecorderRpc + Clone + Send + Sync + 'static,
2028 P: LogPeer + Clone + Send + Sync + 'static,
2029 C: Into<Vec<PeerConfig>>,
2030{
2031 node_rpc_router_with_limits_for_generation(
2032 recorder,
2033 peer,
2034 peers,
2035 recorder_concurrency,
2036 log_concurrency,
2037 1,
2038 )
2039}
2040
2041pub fn node_rpc_router_with_limits_for_generation<R, P, C>(
2042 recorder: R,
2043 peer: P,
2044 peers: C,
2045 recorder_concurrency: usize,
2046 log_concurrency: usize,
2047 recovery_generation: u64,
2048) -> Router
2049where
2050 R: RecorderRpc + Clone + Send + Sync + 'static,
2051 P: LogPeer + Clone + Send + Sync + 'static,
2052 C: Into<Vec<PeerConfig>>,
2053{
2054 let peers = peers.into();
2055 let recorder_slots = Arc::new(tokio::sync::Semaphore::new(recorder_concurrency));
2056 let log_slots = Arc::new(tokio::sync::Semaphore::new(log_concurrency));
2057 recorder_routes(recorder, peers.clone(), recovery_generation, recorder_slots)
2058 .merge(log_routes(peer, peers, recovery_generation, log_slots))
2059 .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
2060}
2061
2062pub fn node_router<R>(runtime: Arc<NodeRuntime>, recorder: R) -> Router
2063where
2064 R: RecorderRpc + Clone + Send + Sync + 'static,
2065{
2066 node_router_with_limits(
2067 runtime,
2068 recorder,
2069 DEFAULT_CLIENT_CONCURRENCY,
2070 DEFAULT_PEER_CONCURRENCY,
2071 )
2072}
2073
2074pub fn node_router_with_limits<R>(
2075 runtime: Arc<NodeRuntime>,
2076 recorder: R,
2077 client_concurrency: usize,
2078 peer_concurrency: usize,
2079) -> Router
2080where
2081 R: RecorderRpc + Clone + Send + Sync + 'static,
2082{
2083 node_router_with_optional_checkpoint(
2084 runtime,
2085 recorder,
2086 None,
2087 client_concurrency,
2088 peer_concurrency,
2089 )
2090}
2091
2092pub fn node_router_with_checkpoint<R>(
2093 runtime: Arc<NodeRuntime>,
2094 recorder: R,
2095 coordinator: Arc<CheckpointCoordinator>,
2096) -> Router
2097where
2098 R: RecorderRpc + Clone + Send + Sync + 'static,
2099{
2100 node_router_with_checkpoint_and_limits(
2101 runtime,
2102 recorder,
2103 coordinator,
2104 DEFAULT_CLIENT_CONCURRENCY,
2105 DEFAULT_PEER_CONCURRENCY,
2106 )
2107}
2108
2109pub fn node_router_with_checkpoint_and_limits<R>(
2110 runtime: Arc<NodeRuntime>,
2111 recorder: R,
2112 coordinator: Arc<CheckpointCoordinator>,
2113 client_concurrency: usize,
2114 peer_concurrency: usize,
2115) -> Router
2116where
2117 R: RecorderRpc + Clone + Send + Sync + 'static,
2118{
2119 node_router_with_optional_checkpoint(
2120 runtime,
2121 recorder,
2122 Some(coordinator),
2123 client_concurrency,
2124 peer_concurrency,
2125 )
2126}
2127
2128fn node_router_with_optional_checkpoint<R>(
2129 runtime: Arc<NodeRuntime>,
2130 recorder: R,
2131 coordinator: Option<Arc<CheckpointCoordinator>>,
2132 client_concurrency: usize,
2133 peer_concurrency: usize,
2134) -> Router
2135where
2136 R: RecorderRpc + Clone + Send + Sync + 'static,
2137{
2138 let peers = runtime.config.peers.clone();
2139 let recovery_generation = runtime.config.recovery_generation();
2140 let client_slots = Arc::new(tokio::sync::Semaphore::new(client_concurrency));
2141 let recorder_slots = Arc::new(tokio::sync::Semaphore::new(peer_concurrency));
2142 let log_slots = Arc::new(tokio::sync::Semaphore::new(peer_concurrency));
2143 let write_operations = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
2144 let (writer, writer_receiver) = tokio::sync::mpsc::channel(client_concurrency.max(1));
2145 tokio::spawn(writer_loop(
2146 Arc::downgrade(&runtime),
2147 coordinator.clone(),
2148 write_operations.clone(),
2149 writer_receiver,
2150 runtime.config.writer_batch_max,
2151 runtime.config.writer_batch_window,
2152 ));
2153 #[cfg(any(feature = "sql", feature = "graph", feature = "kv"))]
2154 let client_routes: Router = match runtime.config().execution_profile() {
2155 ExecutionProfile::Sqlite => {
2156 #[cfg(feature = "sql")]
2157 {
2158 Router::new()
2159 .route(WRITE_PATH, post(handle_write))
2160 .route(READ_PATH, post(handle_read))
2161 .route(SQL_EXECUTE_PATH, post(handle_sql_execute))
2162 .route(SQL_QUERY_PATH, post(handle_sql_query))
2163 }
2164 #[cfg(not(feature = "sql"))]
2165 unreachable!("SQL runtime cannot open without the sql feature")
2166 }
2167 ExecutionProfile::Graph => {
2168 #[cfg(feature = "graph")]
2169 {
2170 Router::new()
2171 .route(GRAPH_PUT_DOCUMENT_PATH, post(handle_graph_put_document))
2172 .route(
2173 GRAPH_DELETE_DOCUMENT_PATH,
2174 post(handle_graph_delete_document),
2175 )
2176 .route(GRAPH_GET_DOCUMENT_PATH, post(handle_graph_get_document))
2177 .route(GRAPH_QUERY_PATH, post(handle_graph_query))
2178 }
2179 #[cfg(not(feature = "graph"))]
2180 unreachable!("graph runtime cannot open without the graph feature")
2181 }
2182 ExecutionProfile::Kv => {
2183 #[cfg(feature = "kv")]
2184 {
2185 Router::new()
2186 .route(KV_PUT_PATH, post(handle_kv_put))
2187 .route(KV_DELETE_PATH, post(handle_kv_delete))
2188 .route(KV_GET_PATH, post(handle_kv_get))
2189 .route(KV_SCAN_PATH, post(handle_kv_scan))
2190 }
2191 #[cfg(not(feature = "kv"))]
2192 unreachable!("KV runtime cannot open without the kv feature")
2193 }
2194 }
2195 .route_layer(middleware::from_fn_with_state(
2196 ClientGateState {
2197 runtime: runtime.clone(),
2198 slots: client_slots,
2199 coordinator: coordinator.clone(),
2200 },
2201 client_gate,
2202 ))
2203 .with_state(NodeRouteState {
2204 runtime: runtime.clone(),
2205 coordinator: coordinator.clone(),
2206 write_operations: write_operations.clone(),
2207 writer: writer.clone(),
2208 });
2209 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
2210 let client_routes: Router = Router::new();
2211 let health_routes = Router::new()
2212 .route(LIVEZ_PATH, get(handle_livez))
2213 .route(READYZ_PATH, get(handle_readyz))
2214 .with_state(NodeRouteState {
2215 runtime: runtime.clone(),
2216 coordinator,
2217 write_operations,
2218 writer,
2219 });
2220 recorder_routes(recorder, peers.clone(), recovery_generation, recorder_slots)
2221 .merge(log_routes(
2222 RuntimeLogPeer { runtime },
2223 peers,
2224 recovery_generation,
2225 log_slots,
2226 ))
2227 .merge(client_routes)
2228 .merge(health_routes)
2229 .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
2230}
2231
2232fn recorder_routes<R>(
2233 recorder: R,
2234 peers: Vec<PeerConfig>,
2235 recovery_generation: u64,
2236 slots: Arc<tokio::sync::Semaphore>,
2237) -> Router
2238where
2239 R: RecorderRpc + Clone + Send + Sync + 'static,
2240{
2241 let recorder_peers = peers.clone();
2242 Router::new()
2243 .route(RECORDER_IDENTITY_PATH, post(handle_recorder_identity::<R>))
2244 .route(
2245 RECORDER_STORE_COMMAND_PATH,
2246 post(handle_recorder_store_command::<R>),
2247 )
2248 .route(
2249 RECORDER_FETCH_COMMAND_PATH,
2250 post(handle_recorder_fetch_command::<R>),
2251 )
2252 .route(
2253 RECORDER_INSPECT_PROOF_PATH,
2254 post(handle_recorder_inspect_proof::<R>),
2255 )
2256 .route(
2257 RECORDER_INSPECT_RECORD_PATH,
2258 post(handle_recorder_inspect_record::<R>),
2259 )
2260 .route(
2261 RECORDER_READ_FENCE_PATH,
2262 post(handle_recorder_read_fence::<R>),
2263 )
2264 .route(RECORDER_RECORD_PATH, post(handle_recorder_record::<R>))
2265 .route(
2266 RECORDER_INSTALL_PROOF_PATH,
2267 post(handle_recorder_install_proof::<R>),
2268 )
2269 .route_layer(middleware::from_fn_with_state(
2270 PeerGateState {
2271 peers,
2272 recovery_generation,
2273 protocol_version: RECORDER_PROTOCOL_VERSION,
2274 slots,
2275 },
2276 peer_gate,
2277 ))
2278 .with_state(RecorderRouteState {
2279 recorder,
2280 peers: recorder_peers,
2281 })
2282}
2283
2284fn log_routes<P>(
2285 peer: P,
2286 peers: Vec<PeerConfig>,
2287 recovery_generation: u64,
2288 slots: Arc<tokio::sync::Semaphore>,
2289) -> Router
2290where
2291 P: LogPeer + Clone + Send + Sync + 'static,
2292{
2293 Router::new()
2294 .route(LOG_FETCH_PATH, post(handle_fetch_log::<P>))
2295 .route_layer(middleware::from_fn_with_state(
2296 PeerGateState {
2297 peers,
2298 recovery_generation,
2299 protocol_version: PROTOCOL_VERSION,
2300 slots,
2301 },
2302 peer_gate,
2303 ))
2304 .with_state(LogRouteState { peer })
2305}
2306
2307async fn handle_recorder_identity<R>(
2308 State(state): State<RecorderRouteState<R>>,
2309 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2310 Json(request): Json<RecorderWire<()>>,
2311) -> Response
2312where
2313 R: RecorderRpc + Clone + Send + Sync + 'static,
2314{
2315 if request.version != RECORDER_WIRE_VERSION {
2316 return StatusCode::BAD_REQUEST.into_response();
2317 }
2318 let recorder = state.recorder;
2319 recorder_v2_response(
2320 tokio::task::spawn_blocking(move || {
2321 let _permit = permit;
2322 recorder.recorder_id()
2323 })
2324 .await,
2325 )
2326}
2327
2328async fn handle_recorder_store_command<R>(
2329 State(state): State<RecorderRouteState<R>>,
2330 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2331 Json(request): Json<RecorderWire<StoreCommandV2>>,
2332) -> Response
2333where
2334 R: RecorderRpc + Clone + Send + Sync + 'static,
2335{
2336 if request.version != RECORDER_WIRE_VERSION || !valid_recorder_command(&request.body.command) {
2337 return StatusCode::BAD_REQUEST.into_response();
2338 }
2339 let recorder = state.recorder;
2340 recorder_v2_response(
2341 tokio::task::spawn_blocking(move || {
2342 let _permit = permit;
2343 let body = request.body;
2344 recorder.store_command_for(
2345 body.cluster_id,
2346 body.epoch,
2347 body.config_id,
2348 body.config_digest,
2349 body.command_hash,
2350 body.command,
2351 )
2352 })
2353 .await,
2354 )
2355}
2356
2357async fn handle_recorder_fetch_command<R>(
2358 State(state): State<RecorderRouteState<R>>,
2359 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2360 Json(request): Json<RecorderWire<FetchCommandV2>>,
2361) -> Response
2362where
2363 R: RecorderRpc + Clone + Send + Sync + 'static,
2364{
2365 if request.version != RECORDER_WIRE_VERSION {
2366 return StatusCode::BAD_REQUEST.into_response();
2367 }
2368 let recorder = state.recorder;
2369 recorder_v2_response(
2370 tokio::task::spawn_blocking(move || {
2371 let _permit = permit;
2372 let body = request.body;
2373 recorder.fetch_command_for(
2374 body.cluster_id,
2375 body.epoch,
2376 body.config_id,
2377 body.config_digest,
2378 body.command_hash,
2379 )
2380 })
2381 .await,
2382 )
2383}
2384
2385async fn handle_recorder_inspect_proof<R>(
2386 State(state): State<RecorderRouteState<R>>,
2387 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2388 Json(request): Json<RecorderWire<InspectProofV2>>,
2389) -> Response
2390where
2391 R: RecorderRpc + Clone + Send + Sync + 'static,
2392{
2393 if request.version != RECORDER_WIRE_VERSION {
2394 return StatusCode::BAD_REQUEST.into_response();
2395 }
2396 let recorder = state.recorder;
2397 recorder_v2_response(
2398 tokio::task::spawn_blocking(move || {
2399 let _permit = permit;
2400 recorder.inspect_decision_proof(request.body.slot)
2401 })
2402 .await,
2403 )
2404}
2405
2406async fn handle_recorder_inspect_record<R>(
2407 State(state): State<RecorderRouteState<R>>,
2408 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2409 Json(request): Json<RecorderWire<InspectProofV2>>,
2410) -> Response
2411where
2412 R: RecorderRpc + Clone + Send + Sync + 'static,
2413{
2414 if request.version != RECORDER_WIRE_VERSION {
2415 return StatusCode::BAD_REQUEST.into_response();
2416 }
2417 let recorder = state.recorder;
2418 recorder_v2_response(
2419 tokio::task::spawn_blocking(move || {
2420 let _permit = permit;
2421 recorder.inspect_record_summary(request.body.slot)
2422 })
2423 .await,
2424 )
2425}
2426
2427async fn handle_recorder_read_fence<R>(
2428 State(state): State<RecorderRouteState<R>>,
2429 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2430 Json(request): Json<RecorderWire<ReadFenceRequest>>,
2431) -> Response
2432where
2433 R: RecorderRpc + Clone + Send + Sync + 'static,
2434{
2435 if request.version != RECORDER_WIRE_VERSION {
2436 return StatusCode::BAD_REQUEST.into_response();
2437 }
2438 let recorder = state.recorder;
2439 recorder_v2_response(
2440 tokio::task::spawn_blocking(move || {
2441 let _permit = permit;
2442 recorder.observe_read_fence(request.body)
2443 })
2444 .await,
2445 )
2446}
2447
2448async fn handle_recorder_record<R>(
2449 State(state): State<RecorderRouteState<R>>,
2450 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2451 Extension(authenticated_peer): Extension<AuthenticatedPeer>,
2452 Json(request): Json<RecorderWire<RecordRequest>>,
2453) -> Response
2454where
2455 R: RecorderRpc + Clone + Send + Sync + 'static,
2456{
2457 if request.version != RECORDER_WIRE_VERSION || !valid_recorder_record(&request.body) {
2458 return StatusCode::BAD_REQUEST.into_response();
2459 }
2460 if !authenticated_proposer_admitted(
2461 &authenticated_peer.0,
2462 &request.body.proposal.proposer_id,
2463 &state.peers,
2464 ) {
2465 return recorder_v2_response::<RecordSummary>(Ok(Err(rhiza_quepaxa::Error::Rejected(
2466 RejectReason::InvalidRequest,
2467 ))));
2468 }
2469 let recorder = state.recorder;
2470 recorder_v2_response(
2471 tokio::task::spawn_blocking(move || {
2472 let _permit = permit;
2473 recorder.record(request.body)
2474 })
2475 .await,
2476 )
2477}
2478
2479fn valid_recorder_command(command: &StoredCommand) -> bool {
2480 command.payload.len() <= MAX_COMMAND_BYTES
2481}
2482
2483fn valid_recorder_record(request: &RecordRequest) -> bool {
2484 !request.cluster_id.is_empty()
2485 && request.cluster_id.len() <= MAX_REQUEST_ID_BYTES
2486 && request.command.as_ref().is_none_or(valid_recorder_command)
2487}
2488
2489fn authenticated_proposer_admitted(
2490 authenticated_peer_id: &str,
2491 proposer_id: &str,
2492 peers: &[PeerConfig],
2493) -> bool {
2494 peers
2497 .iter()
2498 .any(|peer| peer.node_id == authenticated_peer_id)
2499 && peers.iter().any(|peer| peer.node_id == proposer_id)
2500}
2501
2502async fn handle_recorder_install_proof<R>(
2503 State(state): State<RecorderRouteState<R>>,
2504 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2505 Extension(authenticated_peer): Extension<AuthenticatedPeer>,
2506 Json(request): Json<RecorderWire<InstallProofV2>>,
2507) -> Response
2508where
2509 R: RecorderRpc + Clone + Send + Sync + 'static,
2510{
2511 if request.version != RECORDER_WIRE_VERSION {
2512 return StatusCode::BAD_REQUEST.into_response();
2513 }
2514 if !authenticated_proposer_admitted(
2515 &authenticated_peer.0,
2516 &request.body.proof.proposal().proposer_id,
2517 &state.peers,
2518 ) {
2519 return recorder_v2_response::<()>(Ok(Err(rhiza_quepaxa::Error::Rejected(
2520 RejectReason::InvalidRequest,
2521 ))));
2522 }
2523 let recorder = state.recorder;
2524 recorder_v2_response(
2525 tokio::task::spawn_blocking(move || {
2526 let _permit = permit;
2527 let membership = Membership::from_voters(request.body.members)?;
2528 recorder.install_decision_proof(request.body.proof, &membership)
2529 })
2530 .await,
2531 )
2532}
2533
2534fn recorder_v2_response<T: serde::Serialize>(
2535 result: Result<rhiza_quepaxa::Result<T>, tokio::task::JoinError>,
2536) -> Response {
2537 let (status, body) = match result {
2538 Ok(Ok(value)) => (StatusCode::OK, RecorderV2Result::Ok(value)),
2539 Ok(Err(rhiza_quepaxa::Error::Rejected(reason))) => {
2540 (StatusCode::CONFLICT, RecorderV2Result::Rejected(reason))
2541 }
2542 Ok(Err(error)) => (
2543 recorder_error_status(&error),
2544 RecorderV2Result::Error(error.to_string()),
2545 ),
2546 Err(error) => (
2547 StatusCode::INTERNAL_SERVER_ERROR,
2548 RecorderV2Result::Error(error.to_string()),
2549 ),
2550 };
2551 (
2552 status,
2553 Json(RecorderWire {
2554 version: RECORDER_WIRE_VERSION,
2555 body,
2556 }),
2557 )
2558 .into_response()
2559}
2560
2561async fn handle_fetch_log<P>(
2562 State(state): State<LogRouteState<P>>,
2563 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2564 Json(request): Json<FetchLogRequest>,
2565) -> Response
2566where
2567 P: LogPeer + Clone + Send + Sync + 'static,
2568{
2569 if request.from_index == 0 || request.max_entries > MAX_FETCH_ENTRIES {
2570 return StatusCode::BAD_REQUEST.into_response();
2571 }
2572 let peer = state.peer;
2573 let result = tokio::task::spawn_blocking(move || {
2574 let _permit = permit;
2575 peer.fetch_log(request)
2576 })
2577 .await;
2578 match result {
2579 Ok(Ok(response)) => Json(FetchLogHttpResponse::Fetched(response)).into_response(),
2580 Ok(Err(error)) => (
2581 fetch_log_error_status(&error),
2582 Json(FetchLogHttpResponse::Failed(error)),
2583 )
2584 .into_response(),
2585 Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
2586 }
2587}
2588
2589#[cfg(feature = "sql")]
2590async fn handle_write(
2591 State(state): State<NodeRouteState>,
2592 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2593 request: Result<Json<WriteRequest>, JsonRejection>,
2594) -> Response {
2595 let request = match client_json(request) {
2596 Ok(request) => request,
2597 Err(response) => return response,
2598 };
2599 let payload = match canonical_put(&request.request_id, &request.key, &request.value) {
2600 Ok(payload) => payload,
2601 Err(error) => return node_error_response(error),
2602 };
2603 let request_id = request.request_id.clone();
2604 let operation = QueuedOperation::KeyValue {
2605 key: request.key,
2606 value: request.value,
2607 };
2608 coordinate_write(state, permit, request_id, payload, operation).await
2609}
2610
2611#[cfg(feature = "sql")]
2612async fn handle_sql_execute(
2613 State(state): State<NodeRouteState>,
2614 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2615 request: Result<Json<SqlExecuteRequest>, JsonRejection>,
2616) -> Response {
2617 let request = match client_json(request) {
2618 Ok(request) => request,
2619 Err(response) => return response,
2620 };
2621 if let Err(error) = validate_field(
2622 "request_id",
2623 &request.request_id,
2624 MAX_REQUEST_ID_BYTES,
2625 false,
2626 ) {
2627 return node_error_response(error);
2628 }
2629 let command = SqlCommand {
2630 request_id: request.request_id.clone(),
2631 statements: request.statements,
2632 };
2633 let payload = match encode_sql_command_with_index(&command) {
2634 Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
2635 Ok(_) => {
2636 return node_error_response(NodeError::InvalidRequest(format!(
2637 "command exceeds {MAX_COMMAND_BYTES} bytes"
2638 )))
2639 }
2640 Err(error) => return node_error_response(error),
2641 };
2642 let request_id = command.request_id.clone();
2643 coordinate_write(
2644 state,
2645 permit,
2646 request_id,
2647 payload,
2648 QueuedOperation::Sql(command),
2649 )
2650 .await
2651}
2652
2653async fn coordinate_write(
2654 state: NodeRouteState,
2655 permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2656 request_id: String,
2657 payload: Vec<u8>,
2658 operation: QueuedOperation,
2659) -> Response {
2660 let deadline = tokio::time::Instant::now() + CLIENT_WRITE_WAIT_TIMEOUT;
2661 let (mut receiver, queued) = {
2662 let mut operations = state.write_operations.lock().await;
2663 if let Some(operation) = operations.get(&request_id) {
2664 if operation.payload != payload {
2665 return client_error_response(
2666 StatusCode::CONFLICT,
2667 "request_conflict",
2668 false,
2669 "request id is already in flight with a different payload",
2670 None,
2671 );
2672 }
2673 (operation.result.clone(), None)
2674 } else {
2675 let (sender, receiver) = tokio::sync::watch::channel(None);
2676 operations.insert(
2677 request_id.clone(),
2678 WriteOperation {
2679 payload: payload.clone(),
2680 result: receiver.clone(),
2681 },
2682 );
2683 (
2684 receiver,
2685 Some(QueuedWrite {
2686 request_id: request_id.clone(),
2687 payload,
2688 operation,
2689 permit,
2690 sender,
2691 }),
2692 )
2693 }
2694 };
2695 if let Some(queued) = queued {
2696 match tokio::time::timeout_at(deadline, state.writer.send(queued)).await {
2697 Ok(Ok(())) => {}
2698 Ok(Err(_)) => {
2699 state.write_operations.lock().await.remove(&request_id);
2700 return client_error_response(
2701 StatusCode::SERVICE_UNAVAILABLE,
2702 "durability_unavailable",
2703 true,
2704 "writer queue is unavailable",
2705 None,
2706 );
2707 }
2708 Err(_) => {
2709 state.write_operations.lock().await.remove(&request_id);
2710 return client_error_response(
2711 StatusCode::SERVICE_UNAVAILABLE,
2712 "write_timeout",
2713 true,
2714 "write did not enter the queue before the response deadline",
2715 None,
2716 );
2717 }
2718 }
2719 }
2720 let wait = async {
2721 loop {
2722 if let Some(result) = receiver.borrow().clone() {
2723 return result;
2724 }
2725 if receiver.changed().await.is_err() {
2726 return WriteOperationResult::DurabilityUnavailable;
2727 }
2728 }
2729 };
2730 match tokio::time::timeout_at(deadline, wait).await {
2731 Ok(WriteOperationResult::Runtime(Ok(response))) => match response {
2732 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
2733 ClientWriteResponse::Unavailable => {
2734 unreachable!("no execution profiles are compiled in")
2735 }
2736 #[cfg(feature = "sql")]
2737 ClientWriteResponse::KeyValue(response) => Json(response).into_response(),
2738 #[cfg(feature = "sql")]
2739 ClientWriteResponse::Sql(response) => Json(response).into_response(),
2740 #[cfg(feature = "graph")]
2741 ClientWriteResponse::Graph(outcome) => {
2742 Json(graph_mutation_response(outcome)).into_response()
2743 }
2744 #[cfg(feature = "kv")]
2745 ClientWriteResponse::Kv(outcome) => Json(kv_mutation_response(outcome)).into_response(),
2746 },
2747 Ok(WriteOperationResult::Runtime(Err(error))) => node_error_response(error),
2748 Ok(WriteOperationResult::DurabilityUnavailable) => client_error_response(
2749 StatusCode::SERVICE_UNAVAILABLE,
2750 "durability_unavailable",
2751 true,
2752 "durability confirmation is unavailable",
2753 None,
2754 ),
2755 Err(_) => client_error_response(
2756 StatusCode::SERVICE_UNAVAILABLE,
2757 "write_timeout",
2758 true,
2759 "write did not complete before the response deadline",
2760 None,
2761 ),
2762 }
2763}
2764
2765async fn writer_loop(
2766 runtime: std::sync::Weak<NodeRuntime>,
2767 coordinator: Option<Arc<CheckpointCoordinator>>,
2768 write_operations: Arc<tokio::sync::Mutex<HashMap<String, WriteOperation>>>,
2769 mut receiver: tokio::sync::mpsc::Receiver<QueuedWrite>,
2770 batch_max: usize,
2771 batch_window: Duration,
2772) {
2773 while let Some(first) = receiver.recv().await {
2774 let mut queued = Vec::with_capacity(batch_max);
2775 queued.push(first);
2776 let deadline = tokio::time::Instant::now() + batch_window;
2777 while queued.len() < batch_max {
2778 match tokio::time::timeout_at(deadline, receiver.recv()).await {
2779 Ok(Some(next)) => queued.push(next),
2780 Ok(None) | Err(_) => break,
2781 }
2782 }
2783
2784 let mut dispatch = Vec::with_capacity(queued.len());
2785 let mut members = Vec::with_capacity(queued.len());
2786 for queued in queued {
2787 members.push(RuntimeBatchMember {
2788 #[cfg(feature = "sql")]
2789 request_id: queued.request_id.clone(),
2790 payload: queued.payload,
2791 operation: queued.operation,
2792 });
2793 dispatch.push((queued.request_id, queued.sender, queued.permit));
2794 }
2795
2796 let Some(runtime) = runtime.upgrade() else {
2797 for (request_id, sender, _permit) in dispatch {
2798 sender.send_replace(Some(WriteOperationResult::DurabilityUnavailable));
2799 write_operations.lock().await.remove(&request_id);
2800 }
2801 break;
2802 };
2803
2804 let blocking_runtime = runtime.clone();
2805 let results =
2806 tokio::task::spawn_blocking(move || blocking_runtime.execute_client_batch(members))
2807 .await
2808 .unwrap_or_else(|error| {
2809 (0..dispatch.len())
2810 .map(|_| {
2811 Err(NodeError::Fatal(format!(
2812 "writer batch task failed: {error}"
2813 )))
2814 })
2815 .collect()
2816 });
2817 let dispatch = dispatch
2818 .into_iter()
2819 .map(|(request_id, sender, permit)| {
2820 drop(permit);
2821 (request_id, sender)
2822 })
2823 .collect::<Vec<_>>();
2824
2825 let committed_index = results
2826 .iter()
2827 .filter_map(|result| result.as_ref().ok().map(ClientWriteResponse::applied_index))
2828 .max();
2829 let durability_available = if let Some(index) = committed_index {
2830 confirm_write_durability(runtime.as_ref(), coordinator.as_deref(), index)
2831 .await
2832 .is_ok()
2833 } else {
2834 true
2835 };
2836
2837 for ((request_id, sender), result) in dispatch.into_iter().zip(results) {
2838 let delivered = if !durability_available && result.is_ok() {
2839 WriteOperationResult::DurabilityUnavailable
2840 } else {
2841 WriteOperationResult::Runtime(result)
2842 };
2843 sender.send_replace(Some(delivered));
2844 write_operations.lock().await.remove(&request_id);
2845 }
2846 }
2847}
2848
2849#[cfg(feature = "sql")]
2850async fn handle_sql_query(
2851 State(state): State<NodeRouteState>,
2852 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2853 request: Result<Json<SqlQueryRequest>, JsonRejection>,
2854) -> Response {
2855 let request = match client_json(request) {
2856 Ok(request) => request,
2857 Err(response) => return response,
2858 };
2859 let runtime = state.runtime;
2860 let consistency = request
2861 .consistency
2862 .unwrap_or(runtime.config.read_consistency());
2863 let max_rows = request.max_rows.unwrap_or(DEFAULT_SQL_MAX_ROWS);
2864 if max_rows == 0 || max_rows > MAX_SQL_MAX_ROWS {
2865 return node_error_response(NodeError::InvalidRequest(format!(
2866 "max_rows must be between 1 and {MAX_SQL_MAX_ROWS}"
2867 )));
2868 }
2869 let result = tokio::task::spawn_blocking(move || {
2870 let _permit = permit;
2871 runtime.query_sql(&request.statement, consistency, max_rows)
2872 })
2873 .await;
2874 match result {
2875 Ok(Ok(response)) => sql_query_http_response(response),
2876 Ok(Err(error)) => node_error_response(error),
2877 Err(error) => client_task_error(error),
2878 }
2879}
2880
2881#[cfg(feature = "sql")]
2882fn sql_query_http_response(response: SqlQueryResponse) -> Response {
2883 match serde_json::to_vec(&response) {
2884 Ok(encoded) if encoded.len() <= MAX_SQL_RESPONSE_BYTES => (
2885 [(axum::http::header::CONTENT_TYPE, "application/json")],
2886 encoded,
2887 )
2888 .into_response(),
2889 Ok(_) => node_error_response(NodeError::InvalidRequest(format!(
2890 "SQL response exceeds {MAX_SQL_RESPONSE_BYTES} bytes"
2891 ))),
2892 Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
2893 }
2894}
2895
2896#[cfg(feature = "graph")]
2897async fn handle_graph_put_document(
2898 State(state): State<NodeRouteState>,
2899 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2900 request: Result<Json<GraphPutDocumentRequest>, JsonRejection>,
2901) -> Response {
2902 let request = match client_json(request) {
2903 Ok(request) => request,
2904 Err(response) => return response,
2905 };
2906 let value = match GraphValueV1::try_from(request.value) {
2907 Ok(value) => value,
2908 Err(error) => return node_error_response(error),
2909 };
2910 let command = match GraphCommandV1::put_document(request.request_id, request.id, value) {
2911 Ok(command) => command,
2912 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2913 };
2914 execute_graph_mutation(state, permit, command).await
2915}
2916
2917#[cfg(feature = "graph")]
2918async fn handle_graph_delete_document(
2919 State(state): State<NodeRouteState>,
2920 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2921 request: Result<Json<GraphDeleteDocumentRequest>, JsonRejection>,
2922) -> Response {
2923 let request = match client_json(request) {
2924 Ok(request) => request,
2925 Err(response) => return response,
2926 };
2927 let command = match GraphCommandV1::delete_document(request.request_id, request.id) {
2928 Ok(command) => command,
2929 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2930 };
2931 execute_graph_mutation(state, permit, command).await
2932}
2933
2934#[cfg(feature = "graph")]
2935async fn execute_graph_mutation(
2936 state: NodeRouteState,
2937 permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2938 command: GraphCommandV1,
2939) -> Response {
2940 let payload = match encode_replicated_graph_command(&command) {
2941 Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
2942 Ok(_) => {
2943 return node_error_response(NodeError::InvalidRequest(format!(
2944 "command exceeds {MAX_COMMAND_BYTES} bytes"
2945 )))
2946 }
2947 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2948 };
2949 coordinate_write(
2950 state,
2951 permit,
2952 command.request_id().to_owned(),
2953 payload,
2954 QueuedOperation::Graph(command),
2955 )
2956 .await
2957}
2958
2959#[cfg(feature = "graph")]
2960async fn handle_graph_get_document(
2961 State(state): State<NodeRouteState>,
2962 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2963 request: Result<Json<GraphGetDocumentRequest>, JsonRejection>,
2964) -> Response {
2965 let request = match client_json(request) {
2966 Ok(request) => request,
2967 Err(response) => return response,
2968 };
2969 let runtime = state.runtime;
2970 let consistency = request
2971 .consistency
2972 .unwrap_or(runtime.config.read_consistency());
2973 let result = tokio::task::spawn_blocking(move || {
2974 let _permit = permit;
2975 runtime.get_graph_document(&request.id, consistency)
2976 })
2977 .await;
2978 match result {
2979 Ok(Ok(response)) => Json(GraphGetDocumentResponse {
2980 value: response.value.map(GraphValueDto::from),
2981 applied_index: response.applied_index,
2982 hash: response.hash,
2983 })
2984 .into_response(),
2985 Ok(Err(error)) => node_error_response(error),
2986 Err(error) => client_task_error(error),
2987 }
2988}
2989
2990#[cfg(feature = "graph")]
2991fn with_graph_client_permit<T>(
2992 permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2993 response_work: impl FnOnce() -> T,
2994) -> T {
2995 let result = response_work();
2996 drop(permit);
2997 result
2998}
2999
3000#[cfg(feature = "graph")]
3001async fn handle_graph_query(
3002 State(state): State<NodeRouteState>,
3003 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3004 request: Result<Json<GraphQueryRequest>, JsonRejection>,
3005) -> Response {
3006 let request = match client_json(request) {
3007 Ok(request) => request,
3008 Err(response) => return response,
3009 };
3010 let parameters = match request
3011 .statement
3012 .parameters
3013 .into_iter()
3014 .map(|(name, value)| GraphParameterValue::try_from(value).map(|value| (name, value)))
3015 .collect::<Result<BTreeMap<_, _>, _>>()
3016 {
3017 Ok(parameters) => parameters,
3018 Err(error) => return node_error_response(error),
3019 };
3020 let runtime = state.runtime;
3021 let consistency = request
3022 .consistency
3023 .unwrap_or(runtime.config.read_consistency());
3024 let max_rows = request.max_rows.unwrap_or(DEFAULT_GRAPH_MAX_ROWS);
3025 if max_rows == 0 || max_rows > MAX_GRAPH_MAX_ROWS {
3026 return node_error_response(NodeError::InvalidRequest(format!(
3027 "max_rows must be between 1 and {MAX_GRAPH_MAX_ROWS}"
3028 )));
3029 }
3030 let result = tokio::task::spawn_blocking(move || {
3031 runtime.query_graph(
3032 &request.statement.cypher,
3033 ¶meters,
3034 consistency,
3035 max_rows,
3036 )
3037 })
3038 .await;
3039 with_graph_client_permit(permit, || match result {
3040 Ok(Ok(result)) => {
3041 let response = GraphQueryResponse::from(result);
3042 match serde_json::to_vec(&response) {
3043 Ok(encoded) if encoded.len() <= MAX_GRAPH_RESPONSE_BYTES => {
3044 Json(response).into_response()
3045 }
3046 Ok(_) => node_error_response(NodeError::InvalidRequest(format!(
3047 "graph response exceeds {MAX_GRAPH_RESPONSE_BYTES} bytes"
3048 ))),
3049 Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
3050 }
3051 }
3052 Ok(Err(error)) => node_error_response(error),
3053 Err(error) => client_task_error(error),
3054 })
3055}
3056
3057#[cfg(feature = "kv")]
3058async fn handle_kv_put(
3059 State(state): State<NodeRouteState>,
3060 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3061 request: Result<Json<KvPutRequest>, JsonRejection>,
3062) -> Response {
3063 let request = match client_json(request) {
3064 Ok(request) => request,
3065 Err(response) => return response,
3066 };
3067 let key = match decode_base64("key", &request.key) {
3068 Ok(value) => value,
3069 Err(error) => return node_error_response(error),
3070 };
3071 let value = match decode_base64("value", &request.value) {
3072 Ok(value) => value,
3073 Err(error) => return node_error_response(error),
3074 };
3075 let command = match KvCommandV1::put(request.request_id, key, value) {
3076 Ok(command) => command,
3077 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3078 };
3079 execute_kv_mutation(state, permit, command).await
3080}
3081
3082#[cfg(feature = "kv")]
3083async fn handle_kv_delete(
3084 State(state): State<NodeRouteState>,
3085 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3086 request: Result<Json<KvDeleteRequest>, JsonRejection>,
3087) -> Response {
3088 let request = match client_json(request) {
3089 Ok(request) => request,
3090 Err(response) => return response,
3091 };
3092 let key = match decode_base64("key", &request.key) {
3093 Ok(value) => value,
3094 Err(error) => return node_error_response(error),
3095 };
3096 let command = match KvCommandV1::delete(request.request_id, key) {
3097 Ok(command) => command,
3098 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3099 };
3100 execute_kv_mutation(state, permit, command).await
3101}
3102
3103#[cfg(feature = "kv")]
3104async fn execute_kv_mutation(
3105 state: NodeRouteState,
3106 permit: Arc<tokio::sync::OwnedSemaphorePermit>,
3107 command: KvCommandV1,
3108) -> Response {
3109 let payload = match encode_replicated_kv_command(&command) {
3110 Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
3111 Ok(_) => {
3112 return node_error_response(NodeError::InvalidRequest(format!(
3113 "command exceeds {MAX_COMMAND_BYTES} bytes"
3114 )))
3115 }
3116 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3117 };
3118 coordinate_write(
3119 state,
3120 permit,
3121 command.request_id().to_owned(),
3122 payload,
3123 QueuedOperation::Kv(command),
3124 )
3125 .await
3126}
3127
3128#[cfg(feature = "kv")]
3129async fn handle_kv_get(
3130 State(state): State<NodeRouteState>,
3131 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3132 request: Result<Json<KvGetRequest>, JsonRejection>,
3133) -> Response {
3134 let request = match client_json(request) {
3135 Ok(request) => request,
3136 Err(response) => return response,
3137 };
3138 let key = match decode_base64("key", &request.key) {
3139 Ok(value) => value,
3140 Err(error) => return node_error_response(error),
3141 };
3142 let runtime = state.runtime;
3143 let consistency = request
3144 .consistency
3145 .unwrap_or(runtime.config.read_consistency());
3146 let result = tokio::task::spawn_blocking(move || {
3147 let _permit = permit;
3148 runtime.get_kv(&key, consistency)
3149 })
3150 .await;
3151 match result {
3152 Ok(Ok(response)) => Json(KvGetResponse {
3153 value: response.value.as_deref().map(encode_base64),
3154 applied_index: response.applied_index,
3155 hash: response.hash,
3156 })
3157 .into_response(),
3158 Ok(Err(error)) => node_error_response(error),
3159 Err(error) => client_task_error(error),
3160 }
3161}
3162
3163#[cfg(feature = "kv")]
3164enum DecodedKvScan {
3165 Range {
3166 start: Vec<u8>,
3167 end: Option<Vec<u8>>,
3168 },
3169 Prefix(Vec<u8>),
3170}
3171
3172#[cfg(feature = "kv")]
3173async fn handle_kv_scan(
3174 State(state): State<NodeRouteState>,
3175 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3176 request: Result<Json<KvScanRequest>, JsonRejection>,
3177) -> Response {
3178 let request = match client_json(request) {
3179 Ok(request) => request,
3180 Err(response) => return response,
3181 };
3182 let limit = request
3183 .limit
3184 .unwrap_or(usize::try_from(DEFAULT_KV_SCAN_LIMIT).expect("u32 fits usize"));
3185 if limit == 0 || limit > MAX_KV_SCAN_ROWS {
3186 return node_error_response(NodeError::InvalidRequest(format!(
3187 "limit must be between 1 and {MAX_KV_SCAN_ROWS}"
3188 )));
3189 }
3190 let cursor = match request.cursor {
3191 Some(cursor) => match decode_base64("cursor", &cursor) {
3192 Ok(cursor) => Some(cursor),
3193 Err(error) => return node_error_response(error),
3194 },
3195 None => None,
3196 };
3197 let scan = match (request.prefix, request.start, request.end) {
3198 (Some(prefix), None, None) => match decode_base64("prefix", &prefix) {
3199 Ok(prefix) => DecodedKvScan::Prefix(prefix),
3200 Err(error) => return node_error_response(error),
3201 },
3202 (None, Some(start), end) => {
3203 let start = match decode_base64("start", &start) {
3204 Ok(start) => start,
3205 Err(error) => return node_error_response(error),
3206 };
3207 let end = match end {
3208 Some(end) => match decode_base64("end", &end) {
3209 Ok(end) => Some(end),
3210 Err(error) => return node_error_response(error),
3211 },
3212 None => None,
3213 };
3214 DecodedKvScan::Range { start, end }
3215 }
3216 _ => {
3217 return node_error_response(NodeError::InvalidRequest(
3218 "provide either prefix alone or start with optional end".into(),
3219 ))
3220 }
3221 };
3222 let runtime = state.runtime;
3223 let consistency = request
3224 .consistency
3225 .unwrap_or(runtime.config.read_consistency());
3226 let result = tokio::task::spawn_blocking(move || {
3227 let _permit = permit;
3228 match scan {
3229 DecodedKvScan::Range { start, end } => runtime.scan_kv_range(
3230 &start,
3231 end.as_deref(),
3232 limit,
3233 cursor.as_deref(),
3234 consistency,
3235 ),
3236 DecodedKvScan::Prefix(prefix) => {
3237 runtime.scan_kv_prefix(&prefix, limit, cursor.as_deref(), consistency)
3238 }
3239 }
3240 })
3241 .await;
3242 match result {
3243 Ok(Ok(result)) => {
3244 let response = KvScanResponse {
3245 entries: result
3246 .rows()
3247 .iter()
3248 .map(|row| KvScanEntryDto {
3249 key: encode_base64(row.key()),
3250 value: encode_base64(row.value()),
3251 })
3252 .collect(),
3253 next_cursor: result.next_cursor().map(encode_base64),
3254 applied_index: result.tip().applied_index(),
3255 hash: result.tip().applied_hash(),
3256 };
3257 match serde_json::to_vec(&response) {
3258 Ok(encoded) if encoded.len() <= MAX_KV_SCAN_RESPONSE_BYTES => {
3259 Json(response).into_response()
3260 }
3261 Ok(_) => node_error_response(NodeError::ResourceExhausted(format!(
3262 "KV scan response exceeds {MAX_KV_SCAN_RESPONSE_BYTES} bytes"
3263 ))),
3264 Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
3265 }
3266 }
3267 Ok(Err(error)) => node_error_response(error),
3268 Err(error) => client_task_error(error),
3269 }
3270}
3271
3272#[cfg(feature = "sql")]
3273async fn handle_read(
3274 State(state): State<NodeRouteState>,
3275 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3276 request: Result<Json<ReadRequest>, JsonRejection>,
3277) -> Response {
3278 let request = match client_json(request) {
3279 Ok(request) => request,
3280 Err(response) => return response,
3281 };
3282 let runtime = state.runtime;
3283 let consistency = request
3284 .consistency
3285 .unwrap_or(runtime.config.read_consistency());
3286 let result = tokio::task::spawn_blocking(move || {
3287 let _permit = permit;
3288 runtime.read(&request.key, consistency)
3289 })
3290 .await;
3291 match result {
3292 Ok(Ok(response)) => Json(response).into_response(),
3293 Ok(Err(error)) => node_error_response(error),
3294 Err(error) => client_task_error(error),
3295 }
3296}
3297
3298async fn peer_gate(
3299 State(state): State<PeerGateState>,
3300 mut request: Request,
3301 next: Next,
3302) -> Response {
3303 if !recovery_generation_matches(request.headers(), state.recovery_generation) {
3304 return StatusCode::UNAUTHORIZED.into_response();
3305 }
3306 let Some(authenticated_peer) =
3307 authenticated_peer(request.headers(), &state.peers, state.protocol_version)
3308 else {
3309 return StatusCode::UNAUTHORIZED.into_response();
3310 };
3311 let permit = match state.slots.try_acquire_owned() {
3312 Ok(permit) => Arc::new(permit),
3313 Err(_) => return StatusCode::TOO_MANY_REQUESTS.into_response(),
3314 };
3315 request.extensions_mut().insert(permit);
3316 request
3317 .extensions_mut()
3318 .insert(AuthenticatedPeer(authenticated_peer));
3319 next.run(request).await
3320}
3321
3322async fn client_gate(
3323 State(state): State<ClientGateState>,
3324 mut request: Request,
3325 next: Next,
3326) -> Response {
3327 if !client_authenticated(request.headers(), state.runtime.config.client_token()) {
3328 return client_error_response(
3329 StatusCode::UNAUTHORIZED,
3330 "unauthorized",
3331 false,
3332 "client authentication failed",
3333 None,
3334 );
3335 }
3336 if let Some(response) = runtime_readiness_response(state.runtime.as_ref()) {
3337 return response;
3338 }
3339 if client_write_path(request.uri().path())
3340 && state
3341 .coordinator
3342 .as_ref()
3343 .is_some_and(|coordinator| coordinator.write_allowed().is_err())
3344 {
3345 return client_error_response(
3346 StatusCode::SERVICE_UNAVAILABLE,
3347 "writes_unavailable",
3348 true,
3349 "writes are temporarily unavailable",
3350 None,
3351 );
3352 }
3353 let permit = match state.slots.try_acquire_owned() {
3354 Ok(permit) => Arc::new(permit),
3355 Err(_) => {
3356 return client_error_response(
3357 StatusCode::TOO_MANY_REQUESTS,
3358 "overloaded",
3359 true,
3360 "client request capacity is exhausted",
3361 None,
3362 )
3363 }
3364 };
3365 request.extensions_mut().insert(permit);
3366 next.run(request).await
3367}
3368
3369fn client_write_path(path: &str) -> bool {
3370 #[cfg(feature = "sql")]
3371 if matches!(path, WRITE_PATH | SQL_EXECUTE_PATH) {
3372 return true;
3373 }
3374 #[cfg(feature = "graph")]
3375 if matches!(path, GRAPH_PUT_DOCUMENT_PATH | GRAPH_DELETE_DOCUMENT_PATH) {
3376 return true;
3377 }
3378 #[cfg(feature = "kv")]
3379 if matches!(path, KV_PUT_PATH | KV_DELETE_PATH) {
3380 return true;
3381 }
3382 false
3383}
3384
3385async fn handle_livez() -> StatusCode {
3386 StatusCode::OK
3387}
3388
3389async fn handle_readyz(State(state): State<NodeRouteState>) -> StatusCode {
3390 if state.runtime.is_ready()
3391 && state
3392 .runtime
3393 .configuration_state()
3394 .is_ok_and(|configuration| configuration.is_active())
3395 && state
3396 .coordinator
3397 .as_ref()
3398 .is_none_or(|coordinator| coordinator.health() == DurabilityHealth::Available)
3399 {
3400 StatusCode::OK
3401 } else {
3402 StatusCode::SERVICE_UNAVAILABLE
3403 }
3404}
3405
3406fn next_sync_flush_retry(current: Duration) -> Duration {
3407 current.saturating_mul(2).min(SYNC_FLUSH_RETRY_MAX)
3408}
3409
3410pub async fn confirm_write_durability(
3415 runtime: &NodeRuntime,
3416 coordinator: Option<&CheckpointCoordinator>,
3417 index: LogIndex,
3418) -> Result<(), DurabilityError> {
3419 let Some(coordinator) = coordinator else {
3420 return Ok(());
3421 };
3422 coordinator.note_committed(index);
3423 if !matches!(coordinator.mode(), DurabilityMode::Sync) {
3424 return Ok(());
3425 }
3426
3427 let mut retry_delay = SYNC_FLUSH_RETRY_INITIAL;
3428 loop {
3429 if runtime.operation_cancelled.load(Ordering::Acquire) {
3430 return Err(DurabilityError::Unavailable);
3431 }
3432 match coordinator.flush_runtime(runtime, index).await {
3433 Ok(tip) if tip.index() >= index => return Ok(()),
3434 Ok(tip) => {
3435 return Err(DurabilityError::LocalLogGap {
3436 expected: index,
3437 actual: Some(tip.index()),
3438 })
3439 }
3440 Err(DurabilityError::Archive(_) | DurabilityError::Io(_)) => {
3441 let cancelled = runtime.operation_cancelled_notify.notified();
3442 tokio::pin!(cancelled);
3443 cancelled.as_mut().enable();
3444 if runtime.operation_cancelled.load(Ordering::Acquire) {
3445 return Err(DurabilityError::Unavailable);
3446 }
3447 tokio::select! {
3448 () = tokio::time::sleep(retry_delay) => {}
3449 () = &mut cancelled => return Err(DurabilityError::Unavailable),
3450 }
3451 retry_delay = next_sync_flush_retry(retry_delay);
3452 }
3453 Err(error) => return Err(error),
3454 }
3455 }
3456}
3457
3458fn authenticated_peer(
3459 headers: &HeaderMap,
3460 peers: &[PeerConfig],
3461 protocol_version: &str,
3462) -> Option<String> {
3463 if header_text(headers, VERSION_HEADER) != Some(protocol_version) {
3464 return None;
3465 }
3466 let node_id = header_text(headers, NODE_ID_HEADER)?;
3467 let token = bearer_token(headers)?;
3468 peer_credentials_authenticated(node_id, token, peers).then(|| node_id.to_owned())
3469}
3470
3471fn peer_credentials_authenticated(node_id: &str, token: &str, peers: &[PeerConfig]) -> bool {
3472 peers
3473 .iter()
3474 .find(|peer| peer.node_id == node_id)
3475 .is_some_and(|peer| secrets_equal(peer.token.as_bytes(), token.as_bytes()))
3476}
3477
3478fn recovery_generation_matches(headers: &HeaderMap, expected: u64) -> bool {
3479 let expected = expected.to_string();
3480 header_text(headers, RECOVERY_GENERATION_HEADER) == Some(expected.as_str())
3481}
3482
3483fn client_authenticated(headers: &HeaderMap, expected_token: &str) -> bool {
3484 !expected_token.is_empty()
3485 && version_matches(headers)
3486 && bearer_token(headers)
3487 .is_some_and(|token| secrets_equal(expected_token.as_bytes(), token.as_bytes()))
3488}
3489
3490fn version_matches(headers: &HeaderMap) -> bool {
3491 header_text(headers, VERSION_HEADER) == Some(PROTOCOL_VERSION)
3492}
3493
3494fn header_text<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
3495 headers.get(name)?.to_str().ok()
3496}
3497
3498fn bearer_token(headers: &HeaderMap) -> Option<&str> {
3499 header_text(headers, "authorization")?.strip_prefix("Bearer ")
3500}
3501
3502fn secrets_equal(left: &[u8], right: &[u8]) -> bool {
3503 if left.len() != right.len() {
3504 return false;
3505 }
3506 left.iter()
3507 .zip(right)
3508 .fold(0_u8, |difference, (left, right)| {
3509 difference | (left ^ right)
3510 })
3511 == 0
3512}
3513
3514fn node_error_response(error: NodeError) -> Response {
3515 let (status, statement_index) = match &error {
3516 NodeError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, None),
3517 #[cfg(feature = "sql")]
3518 NodeError::InvalidSqlStatement {
3519 statement_index, ..
3520 } => (StatusCode::BAD_REQUEST, Some(*statement_index)),
3521 #[cfg(feature = "sql")]
3522 NodeError::RequestConflict(_) => (StatusCode::CONFLICT, None),
3523 NodeError::PreconditionFailed(_) => (StatusCode::CONFLICT, None),
3524 NodeError::SnapshotRequired(_)
3525 | NodeError::Unavailable(_)
3526 | NodeError::ResourceExhausted(_)
3527 | NodeError::ConfigurationTransition { .. }
3528 | NodeError::Contention(_)
3529 | NodeError::WinnerLimitExceeded => (StatusCode::SERVICE_UNAVAILABLE, None),
3530 NodeError::DataRootLocked(_)
3531 | NodeError::UnsupportedAckMode(_)
3532 | NodeError::ExecutionProfileMismatch { .. }
3533 | NodeError::Storage(_)
3534 | NodeError::Reconciliation(_)
3535 | NodeError::Invariant(_)
3536 | NodeError::Fatal(_) => (StatusCode::INTERNAL_SERVER_ERROR, None),
3537 };
3538 let classification = error.classification();
3539 if !matches!(&error, NodeError::Fatal(_)) {
3540 eprintln!(
3541 "node request failed (code={}, retryable={}): {}",
3542 classification.code(),
3543 classification.retryable(),
3544 escaped_error_detail(&error)
3545 );
3546 }
3547 client_error_response(
3548 status,
3549 classification.code(),
3550 classification.retryable(),
3551 node_error_message(status),
3552 statement_index,
3553 )
3554}
3555
3556fn node_error_message(status: StatusCode) -> &'static str {
3557 match status {
3558 StatusCode::BAD_REQUEST => "request could not be processed",
3559 StatusCode::CONFLICT => "request conflicts with current state",
3560 StatusCode::SERVICE_UNAVAILABLE => "service is temporarily unavailable",
3561 _ => "internal server error",
3562 }
3563}
3564
3565const MAX_ESCAPED_ERROR_DETAIL_BYTES: usize = 4 * 1024;
3566const ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER: &str = "...[truncated]";
3567
3568fn escaped_error_detail(error: &dyn fmt::Display) -> String {
3569 let detail = error.to_string();
3570 let mut escaped = String::with_capacity(detail.len().min(MAX_ESCAPED_ERROR_DETAIL_BYTES));
3571 for character in detail.chars() {
3572 let character_start = escaped.len();
3573 for escaped_character in character.escape_default() {
3574 if escaped.len()
3575 + escaped_character.len_utf8()
3576 + ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER.len()
3577 > MAX_ESCAPED_ERROR_DETAIL_BYTES
3578 {
3579 escaped.truncate(character_start);
3580 escaped.push_str(ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER);
3581 return escaped;
3582 }
3583 escaped.push(escaped_character);
3584 }
3585 }
3586 escaped
3587}
3588
3589#[allow(clippy::result_large_err)]
3590fn client_json<T>(request: Result<Json<T>, JsonRejection>) -> Result<T, Response> {
3591 request.map(|Json(request)| request).map_err(|rejection| {
3592 let status = rejection.status();
3593 eprintln!("invalid JSON request: {}", escaped_error_detail(&rejection));
3594 client_json_error_response(status)
3595 })
3596}
3597
3598fn client_json_error_response(status: StatusCode) -> Response {
3599 let code = if status == StatusCode::PAYLOAD_TOO_LARGE {
3600 "payload_too_large"
3601 } else {
3602 "invalid_json"
3603 };
3604 client_error_response(status, code, false, "request body is invalid", None)
3605}
3606
3607fn client_task_error(error: tokio::task::JoinError) -> Response {
3608 eprintln!("request task failed: {}", escaped_error_detail(&error));
3609 client_error_response(
3610 StatusCode::INTERNAL_SERVER_ERROR,
3611 "task_failed",
3612 false,
3613 "request task failed",
3614 None,
3615 )
3616}
3617
3618fn client_error_response(
3619 status: StatusCode,
3620 code: impl Into<String>,
3621 retryable: bool,
3622 message: impl Into<String>,
3623 statement_index: Option<usize>,
3624) -> Response {
3625 (
3626 status,
3627 Json(ClientErrorResponse {
3628 code: code.into(),
3629 retryable,
3630 message: message.into(),
3631 statement_index,
3632 }),
3633 )
3634 .into_response()
3635}
3636
3637pub fn install_successor_recorder(
3638 recorder: &RecorderFileStore,
3639 next_config_id: u64,
3640 membership: Membership,
3641 stop: &StopInformation,
3642) -> Result<rhiza_quepaxa::ConfigurationState, NodeError> {
3643 if stop.entry.config_id.checked_add(1) != Some(next_config_id) {
3644 return Err(NodeError::PreconditionFailed(
3645 "successor identity does not match the Stop proof".into(),
3646 ));
3647 }
3648 recorder
3649 .install_successor_from_proof(membership, &stop.proof)
3650 .map_err(|error| NodeError::Reconciliation(error.to_string()))
3651}
3652
3653pub fn recover_successor_recorder_after_checkpoint(
3654 recorder: &RecorderFileStore,
3655 config: &NodeConfig,
3656 next_config_id: u64,
3657 membership: Membership,
3658 stop: &StopInformation,
3659) -> Result<rhiza_quepaxa::ConfigurationState, NodeError> {
3660 let installed = install_successor_recorder(recorder, next_config_id, membership.clone(), stop)?;
3661 let log = FileLogStore::open_with_configuration(
3662 config.data_dir.join("consensus/log"),
3663 &config.cluster_id,
3664 config.epoch,
3665 config.log_initial_configuration.clone(),
3666 )
3667 .map_err(|error| NodeError::Storage(error.to_string()))?;
3668 let recovered_configuration = log
3669 .configuration_state()
3670 .map_err(|error| NodeError::Storage(error.to_string()))?;
3671 if !recovered_configuration.is_active() {
3672 return Ok(installed);
3673 }
3674 if recovered_configuration.config_id() != next_config_id
3675 || recovered_configuration.digest() != membership.digest()
3676 {
3677 return Err(NodeError::Reconciliation(
3678 "recovered successor qlog configuration does not match the target bundle".into(),
3679 ));
3680 }
3681 let tip = log
3682 .logical_state()
3683 .map_err(|error| NodeError::Storage(error.to_string()))?
3684 .tip
3685 .ok_or_else(|| NodeError::Reconciliation("recovered successor qlog is empty".into()))?;
3686 recorder
3687 .recover_successor_activation_from_checkpoint(
3688 stop.entry.index,
3689 stop.entry.hash,
3690 tip.index(),
3691 tip.hash(),
3692 )
3693 .map_err(|error| NodeError::Reconciliation(error.to_string()))
3694}
3695
3696fn recorder_error_status(error: &rhiza_quepaxa::Error) -> StatusCode {
3697 match error {
3698 rhiza_quepaxa::Error::NoQuorum
3699 | rhiza_quepaxa::Error::CommandUnavailable
3700 | rhiza_quepaxa::Error::Io(_)
3701 | rhiza_quepaxa::Error::RecorderRootLocked(_) => StatusCode::SERVICE_UNAVAILABLE,
3702 rhiza_quepaxa::Error::Rejected(_) => StatusCode::CONFLICT,
3703 _ => StatusCode::INTERNAL_SERVER_ERROR,
3704 }
3705}
3706
3707fn fetch_log_error_status(error: &FetchLogError) -> StatusCode {
3708 match error {
3709 FetchLogError::InvalidRequest { .. } => StatusCode::BAD_REQUEST,
3710 FetchLogError::SnapshotRequired { .. } | FetchLogError::Gap { .. } => StatusCode::CONFLICT,
3711 FetchLogError::Decode { .. } | FetchLogError::Transport { .. } => {
3712 StatusCode::SERVICE_UNAVAILABLE
3713 }
3714 FetchLogError::InvalidAnchor { .. }
3715 | FetchLogError::InvalidEntry { .. }
3716 | FetchLogError::ForeignIdentity { .. } => StatusCode::INTERNAL_SERVER_ERROR,
3717 }
3718}
3719
3720fn runtime_readiness_response(runtime: &NodeRuntime) -> Option<Response> {
3721 if runtime.is_fatal() {
3722 Some(client_error_response(
3723 StatusCode::INTERNAL_SERVER_ERROR,
3724 "fatal",
3725 false,
3726 "node is fatally unavailable",
3727 None,
3728 ))
3729 } else if !runtime.is_ready() {
3730 Some(client_error_response(
3731 StatusCode::SERVICE_UNAVAILABLE,
3732 "unavailable",
3733 true,
3734 "node is not ready",
3735 None,
3736 ))
3737 } else {
3738 None
3739 }
3740}
3741
3742#[derive(Debug, serde::Deserialize, serde::Serialize)]
3743#[serde(tag = "status", content = "body")]
3744enum FetchLogHttpResponse {
3745 Fetched(FetchLogResponse),
3746 Failed(FetchLogError),
3747}
3748
3749pub fn catch_up_missing_entries<P: LogPeer + ?Sized>(
3750 local_last_index: LogIndex,
3751 local_last_hash: LogHash,
3752 cluster_id: &str,
3753 epoch: u64,
3754 config_id: u64,
3755 peer: &P,
3756 max_entries: u32,
3757) -> Result<Vec<LogEntry>, FetchLogError> {
3758 if max_entries == 0 {
3759 return Ok(Vec::new());
3760 }
3761 if max_entries > MAX_FETCH_ENTRIES {
3762 return Err(FetchLogError::InvalidRequest {
3763 message: format!("max_entries exceeds {MAX_FETCH_ENTRIES}"),
3764 });
3765 }
3766 if cluster_id.is_empty() {
3767 return Err(FetchLogError::InvalidRequest {
3768 message: "cluster_id must not be empty".into(),
3769 });
3770 }
3771 let from_index =
3772 local_last_index
3773 .checked_add(1)
3774 .ok_or_else(|| FetchLogError::InvalidRequest {
3775 message: "local qlog index is exhausted".into(),
3776 })?;
3777 let response = peer.fetch_log(FetchLogRequest {
3778 from_index,
3779 max_entries,
3780 })?;
3781 if response.entries.len() > max_entries as usize {
3782 return Err(FetchLogError::InvalidRequest {
3783 message: "peer returned more entries than requested".into(),
3784 });
3785 }
3786 if response.last_index < local_last_index {
3787 return Err(FetchLogError::Gap {
3788 expected: local_last_index,
3789 actual: Some(response.last_index),
3790 });
3791 }
3792 if response.entries.is_empty() && response.last_index >= from_index {
3793 return Err(FetchLogError::Gap {
3794 expected: from_index,
3795 actual: None,
3796 });
3797 }
3798 validate_fetched_entries(
3799 from_index,
3800 local_last_hash,
3801 cluster_id,
3802 epoch,
3803 config_id,
3804 response.entries,
3805 )
3806}
3807
3808fn validate_fetched_entries(
3809 from_index: LogIndex,
3810 local_last_hash: LogHash,
3811 cluster_id: &str,
3812 epoch: u64,
3813 config_id: u64,
3814 entries: Vec<LogEntry>,
3815) -> Result<Vec<LogEntry>, FetchLogError> {
3816 validate_fetched_entries_with_configuration(
3817 from_index,
3818 local_last_hash,
3819 cluster_id,
3820 epoch,
3821 ConfigurationState::active(config_id, LogHash::ZERO),
3822 entries,
3823 )
3824}
3825
3826fn validate_fetched_entries_with_configuration(
3827 from_index: LogIndex,
3828 local_last_hash: LogHash,
3829 cluster_id: &str,
3830 epoch: u64,
3831 mut configuration_state: ConfigurationState,
3832 entries: Vec<LogEntry>,
3833) -> Result<Vec<LogEntry>, FetchLogError> {
3834 let mut expected = from_index;
3835 let mut previous_hash = local_last_hash;
3836 for entry in &entries {
3837 if entry.index != expected {
3838 return Err(FetchLogError::Gap {
3839 expected,
3840 actual: Some(entry.index),
3841 });
3842 }
3843 if entry.cluster_id != cluster_id || entry.epoch != epoch {
3844 return Err(FetchLogError::ForeignIdentity { index: entry.index });
3845 }
3846 if entry.prev_hash != previous_hash {
3847 return Err(FetchLogError::InvalidAnchor {
3848 expected: previous_hash,
3849 actual: entry.prev_hash,
3850 });
3851 }
3852 if entry.recompute_hash() != entry.hash {
3853 return Err(FetchLogError::InvalidEntry {
3854 index: entry.index,
3855 message: "hash does not match entry contents".into(),
3856 });
3857 }
3858 validate_entry_shape(entry).map_err(|message| FetchLogError::InvalidEntry {
3859 index: entry.index,
3860 message,
3861 })?;
3862 configuration_state = configuration_state.validate_entry(entry).map_err(|error| {
3863 FetchLogError::InvalidEntry {
3864 index: entry.index,
3865 message: error.to_string(),
3866 }
3867 })?;
3868 expected = expected
3869 .checked_add(1)
3870 .ok_or_else(|| FetchLogError::InvalidEntry {
3871 index: entry.index,
3872 message: "qlog index is exhausted".into(),
3873 })?;
3874 previous_hash = entry.hash;
3875 }
3876 Ok(entries)
3877}
3878
3879#[derive(Clone, Eq, PartialEq)]
3880pub struct PeerConfig {
3881 node_id: String,
3882 base_url: String,
3883 log_base_url: String,
3884 token: String,
3885}
3886
3887impl fmt::Debug for PeerConfig {
3888 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3889 f.debug_struct("PeerConfig")
3890 .field("node_id", &self.node_id)
3891 .field("base_url", &self.base_url)
3892 .field("log_base_url", &self.log_base_url)
3893 .field("token", &"[redacted]")
3894 .finish()
3895 }
3896}
3897
3898impl PeerConfig {
3899 pub fn new(
3900 node_id: impl Into<String>,
3901 base_url: impl Into<String>,
3902 token: impl Into<String>,
3903 ) -> Result<Self, ConfigError> {
3904 let base_url = base_url.into();
3905 Self::new_with_log_url(node_id, base_url.clone(), base_url, token)
3906 }
3907
3908 pub fn new_with_log_url(
3909 node_id: impl Into<String>,
3910 base_url: impl Into<String>,
3911 log_base_url: impl Into<String>,
3912 token: impl Into<String>,
3913 ) -> Result<Self, ConfigError> {
3914 let node_id = node_id.into();
3915 if !valid_nonblank_header_value(&node_id) {
3916 return Err(ConfigError::EmptyPeerNodeId);
3917 }
3918 let base_url = validate_peer_base_url(base_url.into())?;
3919 let log_base_url = validate_peer_base_url(log_base_url.into())?;
3920 let token = token.into();
3921 if !valid_auth_token(&token) {
3922 return Err(ConfigError::EmptyPeerToken);
3923 }
3924 Ok(Self {
3925 node_id,
3926 base_url,
3927 log_base_url,
3928 token,
3929 })
3930 }
3931
3932 pub fn node_id(&self) -> &str {
3933 &self.node_id
3934 }
3935
3936 pub fn base_url(&self) -> &str {
3937 &self.base_url
3938 }
3939
3940 pub fn log_base_url(&self) -> &str {
3941 &self.log_base_url
3942 }
3943
3944 pub fn token(&self) -> &str {
3945 &self.token
3946 }
3947}
3948
3949fn validate_peer_base_url(url: String) -> Result<String, ConfigError> {
3950 let url = url.trim_end_matches('/').to_string();
3951 if url.trim().is_empty() {
3952 return Err(ConfigError::EmptyPeerBaseUrl);
3953 }
3954 let parsed =
3955 reqwest::Url::parse(&url).map_err(|_| ConfigError::InvalidPeerBaseUrl(url.clone()))?;
3956 if !matches!(parsed.scheme(), "http" | "https")
3957 || parsed.host_str().is_none()
3958 || !parsed.username().is_empty()
3959 || parsed.password().is_some()
3960 || parsed.path() != "/"
3961 || parsed.query().is_some()
3962 || parsed.fragment().is_some()
3963 {
3964 return Err(ConfigError::InvalidPeerBaseUrl(url));
3965 }
3966 Ok(url)
3967}
3968
3969pub(crate) fn valid_nonblank_header_value(value: &str) -> bool {
3970 !value.trim().is_empty()
3971 && axum::http::HeaderValue::try_from(value).is_ok_and(|value| value.to_str().is_ok())
3972}
3973
3974pub(crate) fn valid_auth_token(value: &str) -> bool {
3975 valid_nonblank_header_value(value) && !value.chars().any(char::is_whitespace)
3976}
3977
3978#[derive(Clone, Eq, PartialEq)]
3979pub struct NodeConfig {
3980 cluster_id_source: String,
3981 logical_cluster_id: String,
3982 cluster_id: String,
3983 node_id: String,
3984 data_dir: PathBuf,
3985 epoch: u64,
3986 membership: Membership,
3987 log_initial_configuration: ConfigurationState,
3988 configuration_state: ConfigurationState,
3989 predecessor_stop_entry: Option<LogEntry>,
3990 recovery_generation: u64,
3991 peers: Vec<PeerConfig>,
3992 client_token: String,
3993 read_consistency: ReadConsistency,
3994 ack_mode: AckMode,
3995 writer_batch_max: usize,
3996 writer_batch_window: Duration,
3997 execution_profile: ExecutionProfile,
3998 #[cfg(feature = "sql")]
3999 sql_write_profiler: Option<SqlWriteProfiler>,
4000 #[cfg(feature = "sql")]
4001 sql_group_commit_queue_capacity: usize,
4002}
4003
4004impl fmt::Debug for NodeConfig {
4005 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4006 let mut debug = f.debug_struct("NodeConfig");
4007 debug
4008 .field("cluster_id_source", &self.cluster_id_source)
4009 .field("logical_cluster_id", &self.logical_cluster_id)
4010 .field("cluster_id", &self.cluster_id)
4011 .field("node_id", &self.node_id)
4012 .field("data_dir", &self.data_dir)
4013 .field("epoch", &self.epoch)
4014 .field("membership", &self.membership.members())
4015 .field("log_initial_configuration", &self.log_initial_configuration)
4016 .field("configuration_state", &self.configuration_state)
4017 .field(
4018 "predecessor_stop_entry",
4019 &self
4020 .predecessor_stop_entry
4021 .as_ref()
4022 .map(|entry| (entry.index, entry.hash)),
4023 )
4024 .field("recovery_generation", &self.recovery_generation)
4025 .field("peers", &self.peers)
4026 .field("client_token", &"[redacted]")
4027 .field("read_consistency", &self.read_consistency)
4028 .field("ack_mode", &self.ack_mode)
4029 .field("writer_batch_max", &self.writer_batch_max)
4030 .field("writer_batch_window", &self.writer_batch_window)
4031 .field("execution_profile", &self.execution_profile);
4032 #[cfg(feature = "sql")]
4033 debug.field(
4034 "sql_write_profiler",
4035 &self.sql_write_profiler.as_ref().map(|_| "installed"),
4036 );
4037 #[cfg(feature = "sql")]
4038 debug.field(
4039 "sql_group_commit_queue_capacity",
4040 &self.sql_group_commit_queue_capacity,
4041 );
4042 debug.finish()
4043 }
4044}
4045
4046impl NodeConfig {
4047 pub fn new<P>(
4048 cluster_id: impl Into<String>,
4049 node_id: impl Into<String>,
4050 data_dir: PathBuf,
4051 epoch: u64,
4052 config_id: u64,
4053 peers: P,
4054 client_token: impl Into<String>,
4055 ) -> Result<Self, ConfigError>
4056 where
4057 P: Into<Vec<PeerConfig>>,
4058 {
4059 let peers = peers.into();
4060 let membership = membership_from_peers(&peers)?;
4061 let configuration_state = ConfigurationState::active(config_id, membership.digest());
4062 Self::new_with_configuration(
4063 cluster_id,
4064 node_id,
4065 data_dir,
4066 epoch,
4067 membership,
4068 configuration_state,
4069 peers,
4070 client_token,
4071 )
4072 }
4073
4074 pub fn new_embedded<I, S>(
4075 cluster_id: impl Into<String>,
4076 node_id: impl Into<String>,
4077 data_dir: PathBuf,
4078 epoch: u64,
4079 config_id: u64,
4080 members: I,
4081 ) -> Result<Self, ConfigError>
4082 where
4083 I: IntoIterator<Item = S>,
4084 S: Into<String>,
4085 {
4086 let cluster_id = cluster_id.into();
4087 let node_id = node_id.into();
4088 validate_node_identity(&cluster_id, &node_id, &data_dir, epoch, config_id)?;
4089 let membership = membership_from_node_ids(members.into_iter().map(Into::into).collect())?;
4090 if !membership.contains(&node_id) {
4091 return Err(ConfigError::LocalNodeMissing);
4092 }
4093 let configuration_state = ConfigurationState::active(config_id, membership.digest());
4094 Self::from_validated_parts(
4095 cluster_id,
4096 node_id,
4097 data_dir,
4098 epoch,
4099 membership,
4100 configuration_state,
4101 Vec::new(),
4102 String::new(),
4103 )
4104 }
4105
4106 #[allow(clippy::too_many_arguments)]
4107 pub fn new_with_configuration<P>(
4108 cluster_id: impl Into<String>,
4109 node_id: impl Into<String>,
4110 data_dir: PathBuf,
4111 epoch: u64,
4112 membership: Membership,
4113 configuration_state: ConfigurationState,
4114 peers: P,
4115 client_token: impl Into<String>,
4116 ) -> Result<Self, ConfigError>
4117 where
4118 P: Into<Vec<PeerConfig>>,
4119 {
4120 let cluster_id = cluster_id.into();
4121 let node_id = node_id.into();
4122 let client_token = client_token.into();
4123 let peers = peers.into();
4124
4125 validate_node_identity(
4126 &cluster_id,
4127 &node_id,
4128 &data_dir,
4129 epoch,
4130 configuration_state.config_id(),
4131 )?;
4132 if !(3..=7).contains(&peers.len()) {
4133 return Err(ConfigError::InvalidPeerCount(peers.len()));
4134 }
4135 let mut peer_ids = HashSet::with_capacity(peers.len());
4136 let mut peer_tokens = HashSet::with_capacity(peers.len());
4137 for peer in &peers {
4138 if !peer_ids.insert(peer.node_id.clone()) {
4139 return Err(ConfigError::DuplicatePeerNodeId(peer.node_id.clone()));
4140 }
4141 if !peer_tokens.insert(peer.token.as_str()) {
4142 return Err(ConfigError::DuplicatePeerToken);
4143 }
4144 }
4145 if !peer_ids.contains(&node_id) {
4146 return Err(ConfigError::LocalNodeMissing);
4147 }
4148 if peer_ids.len() != membership.members().len()
4149 || membership
4150 .members()
4151 .iter()
4152 .any(|member| !peer_ids.contains(member))
4153 {
4154 return Err(ConfigError::PeerMembershipMismatch);
4155 }
4156 if configuration_state.is_active() && configuration_state.digest() != membership.digest() {
4157 return Err(ConfigError::PeerMembershipMismatch);
4158 }
4159 if !valid_auth_token(&client_token) {
4160 return Err(ConfigError::EmptyClientToken);
4161 }
4162 if peer_tokens.contains(client_token.as_str()) {
4163 return Err(ConfigError::ClientTokenConflictsWithPeer);
4164 }
4165
4166 Self::from_validated_parts(
4167 cluster_id,
4168 node_id,
4169 data_dir,
4170 epoch,
4171 membership,
4172 configuration_state,
4173 peers,
4174 client_token,
4175 )
4176 }
4177
4178 #[allow(clippy::too_many_arguments)]
4179 fn from_validated_parts(
4180 cluster_id: String,
4181 node_id: String,
4182 data_dir: PathBuf,
4183 epoch: u64,
4184 membership: Membership,
4185 configuration_state: ConfigurationState,
4186 peers: Vec<PeerConfig>,
4187 client_token: String,
4188 ) -> Result<Self, ConfigError> {
4189 let log_initial_configuration = ConfigurationState::active(
4190 configuration_state.config_id(),
4191 configuration_state.digest(),
4192 );
4193 let execution_profile =
4194 canonical_cluster_profile(&cluster_id).unwrap_or(ExecutionProfile::Sqlite);
4195 let logical_cluster_id = ["rhiza:sql:", "rhiza:graph:", "rhiza:kv:"]
4196 .into_iter()
4197 .find_map(|prefix| cluster_id.strip_prefix(prefix))
4198 .unwrap_or(&cluster_id)
4199 .to_owned();
4200 let effective_cluster_id = effective_cluster_id(execution_profile, &cluster_id)?;
4201 Ok(Self {
4202 cluster_id_source: cluster_id,
4203 cluster_id: effective_cluster_id,
4204 logical_cluster_id,
4205 node_id,
4206 data_dir,
4207 epoch,
4208 membership,
4209 log_initial_configuration,
4210 configuration_state,
4211 predecessor_stop_entry: None,
4212 recovery_generation: 1,
4213 peers,
4214 client_token,
4215 read_consistency: ReadConsistency::ReadBarrier,
4216 ack_mode: AckMode::HaFirst,
4217 writer_batch_max: DEFAULT_WRITER_BATCH_MAX,
4218 writer_batch_window: DEFAULT_WRITER_BATCH_WINDOW,
4219 execution_profile,
4220 #[cfg(feature = "sql")]
4221 sql_write_profiler: None,
4222 #[cfg(feature = "sql")]
4223 sql_group_commit_queue_capacity: DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY,
4224 })
4225 }
4226
4227 pub fn with_execution_profile(
4228 mut self,
4229 execution_profile: ExecutionProfile,
4230 ) -> Result<Self, ConfigError> {
4231 self.cluster_id = effective_cluster_id(execution_profile, &self.cluster_id_source)?;
4232 self.execution_profile = execution_profile;
4233 Ok(self)
4234 }
4235
4236 pub fn with_read_consistency(mut self, read_consistency: ReadConsistency) -> Self {
4237 self.read_consistency = read_consistency;
4238 self
4239 }
4240
4241 #[cfg(feature = "sql")]
4242 pub fn with_sql_write_profiler(mut self, profiler: SqlWriteProfiler) -> Self {
4243 self.sql_write_profiler = Some(profiler);
4244 self
4245 }
4246
4247 #[cfg(feature = "sql")]
4248 pub fn with_sql_group_commit_queue_capacity(
4249 mut self,
4250 capacity: usize,
4251 ) -> Result<Self, ConfigError> {
4252 if !(1..=MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY).contains(&capacity) {
4253 return Err(ConfigError::InvalidSqlGroupCommitQueueCapacity(capacity));
4254 }
4255 self.sql_group_commit_queue_capacity = capacity;
4256 Ok(self)
4257 }
4258
4259 pub fn with_ack_mode(mut self, ack_mode: AckMode) -> Self {
4260 self.ack_mode = ack_mode;
4261 self
4262 }
4263
4264 pub fn with_writer_batching(
4265 mut self,
4266 max: usize,
4267 window: Duration,
4268 ) -> Result<Self, ConfigError> {
4269 if max == 0 || max > MAX_WRITE_BATCH_MEMBERS {
4270 return Err(ConfigError::InvalidWriterBatchMax(max));
4271 }
4272 if window.is_zero() || window >= CLIENT_WRITE_WAIT_TIMEOUT {
4273 return Err(ConfigError::InvalidWriterBatchWindow);
4274 }
4275 self.writer_batch_max = max;
4276 self.writer_batch_window = window;
4277 Ok(self)
4278 }
4279
4280 pub fn with_log_initial_configuration(mut self, configuration: ConfigurationState) -> Self {
4281 self.log_initial_configuration = configuration;
4282 self
4283 }
4284
4285 pub fn with_predecessor_stop_entry(mut self, entry: LogEntry) -> Self {
4286 self.predecessor_stop_entry = Some(entry);
4287 self
4288 }
4289
4290 pub fn bind_predecessor_stop(
4292 mut self,
4293 predecessor_membership: &Membership,
4294 entry: LogEntry,
4295 ) -> Result<Self, ConfigError> {
4296 let target_config_id = self.config_id();
4297 if entry.cluster_id != self.cluster_id
4298 || entry.epoch != self.epoch
4299 || entry.config_id.checked_add(1) != Some(target_config_id)
4300 || entry.recompute_hash() != entry.hash
4301 {
4302 return Err(ConfigError::InvalidPredecessorTransition(
4303 "Stop identity does not match the successor draft".into(),
4304 ));
4305 }
4306 let ConfigChange::BoundStop { successor } =
4307 ConfigChange::recognize_parts(entry.entry_type, &entry.payload).map_err(|_| {
4308 ConfigError::InvalidPredecessorTransition(
4309 "predecessor entry is not a bound Stop".into(),
4310 )
4311 })?
4312 else {
4313 return Err(ConfigError::InvalidPredecessorTransition(
4314 "predecessor entry is not a bound Stop".into(),
4315 ));
4316 };
4317 if successor.cluster_id() != self.cluster_id
4318 || successor.predecessor_config_id() != entry.config_id
4319 || successor.predecessor_config_digest() != predecessor_membership.digest()
4320 || successor.config_id() != target_config_id
4321 || successor.digest() != self.membership.digest()
4322 || successor.members() != self.membership.members()
4323 {
4324 return Err(ConfigError::InvalidPredecessorTransition(
4325 "Stop successor descriptor does not match the successor draft".into(),
4326 ));
4327 }
4328 let predecessor =
4329 ConfigurationState::active(entry.config_id, predecessor_membership.digest());
4330 let stopped = predecessor
4331 .validate_entry(&entry)
4332 .map_err(|error| ConfigError::InvalidPredecessorTransition(error.to_string()))?;
4333 self.log_initial_configuration = predecessor;
4334 self.configuration_state = stopped;
4335 self.predecessor_stop_entry = Some(entry);
4336 Ok(self)
4337 }
4338
4339 pub fn with_recovery_generation(
4340 mut self,
4341 recovery_generation: u64,
4342 ) -> Result<Self, ConfigError> {
4343 validate_recovery_generation(recovery_generation)?;
4344 self.recovery_generation = recovery_generation;
4345 Ok(self)
4346 }
4347
4348 pub fn cluster_id(&self) -> &str {
4349 &self.cluster_id
4350 }
4351
4352 pub fn logical_cluster_id(&self) -> &str {
4353 &self.logical_cluster_id
4354 }
4355
4356 pub fn node_id(&self) -> &str {
4357 &self.node_id
4358 }
4359
4360 pub fn data_dir(&self) -> &PathBuf {
4361 &self.data_dir
4362 }
4363
4364 pub const fn epoch(&self) -> u64 {
4365 self.epoch
4366 }
4367
4368 pub const fn config_id(&self) -> u64 {
4369 self.configuration_state.config_id()
4370 }
4371
4372 pub const fn recovery_generation(&self) -> u64 {
4373 self.recovery_generation
4374 }
4375
4376 pub fn peers(&self) -> &[PeerConfig] {
4377 &self.peers
4378 }
4379
4380 pub const fn membership(&self) -> &Membership {
4381 &self.membership
4382 }
4383
4384 pub const fn configuration_state(&self) -> &ConfigurationState {
4385 &self.configuration_state
4386 }
4387
4388 pub const fn log_initial_configuration(&self) -> &ConfigurationState {
4389 &self.log_initial_configuration
4390 }
4391
4392 pub fn client_token(&self) -> &str {
4393 &self.client_token
4394 }
4395
4396 pub const fn read_consistency(&self) -> ReadConsistency {
4397 self.read_consistency
4398 }
4399
4400 pub const fn ack_mode(&self) -> AckMode {
4401 self.ack_mode
4402 }
4403
4404 pub const fn writer_batch_max(&self) -> usize {
4405 self.writer_batch_max
4406 }
4407
4408 pub const fn writer_batch_window(&self) -> Duration {
4409 self.writer_batch_window
4410 }
4411
4412 pub const fn execution_profile(&self) -> ExecutionProfile {
4413 self.execution_profile
4414 }
4415
4416 #[cfg(feature = "sql")]
4417 pub const fn sql_write_profiler(&self) -> Option<&SqlWriteProfiler> {
4418 self.sql_write_profiler.as_ref()
4419 }
4420
4421 #[cfg(feature = "sql")]
4422 pub const fn sql_group_commit_queue_capacity(&self) -> usize {
4423 self.sql_group_commit_queue_capacity
4424 }
4425}
4426
4427fn validate_node_identity(
4428 cluster_id: &str,
4429 node_id: &str,
4430 data_dir: &Path,
4431 epoch: u64,
4432 config_id: u64,
4433) -> Result<(), ConfigError> {
4434 if cluster_id.trim().is_empty() {
4435 return Err(ConfigError::EmptyClusterId);
4436 }
4437 if node_id.trim().is_empty() {
4438 return Err(ConfigError::EmptyNodeId);
4439 }
4440 if data_dir.as_os_str().is_empty() {
4441 return Err(ConfigError::EmptyDataDir);
4442 }
4443 if epoch == 0 {
4444 return Err(ConfigError::InvalidEpoch);
4445 }
4446 if config_id == 0 {
4447 return Err(ConfigError::InvalidConfigId);
4448 }
4449 Ok(())
4450}
4451
4452fn membership_from_node_ids(members: Vec<String>) -> Result<Membership, ConfigError> {
4453 if !(3..=7).contains(&members.len()) {
4454 return Err(ConfigError::InvalidPeerCount(members.len()));
4455 }
4456 Membership::from_voters(members.clone()).map_err(|error| match error {
4457 rhiza_quepaxa::Error::DuplicateRecorderIdentity => {
4458 let duplicate = members
4459 .iter()
4460 .find(|candidate| {
4461 members
4462 .iter()
4463 .filter(|member| *member == *candidate)
4464 .count()
4465 > 1
4466 })
4467 .cloned()
4468 .unwrap_or_default();
4469 ConfigError::DuplicatePeerNodeId(duplicate)
4470 }
4471 rhiza_quepaxa::Error::EmptyRecorderIdentity => ConfigError::EmptyPeerNodeId,
4472 _ => ConfigError::InvalidPeerCount(members.len()),
4473 })
4474}
4475
4476fn membership_from_peers(peers: &[PeerConfig]) -> Result<Membership, ConfigError> {
4477 membership_from_node_ids(peers.iter().map(|peer| peer.node_id.clone()).collect())
4478}
4479
4480#[derive(Clone, Debug, Eq, PartialEq)]
4481pub enum NodeError {
4482 UnsupportedAckMode(AckMode),
4483 ExecutionProfileMismatch {
4484 expected: ExecutionProfile,
4485 actual: ExecutionProfile,
4486 },
4487 DataRootLocked(PathBuf),
4488 SnapshotRequired(Box<RecoveryAnchor>),
4489 Storage(String),
4490 Reconciliation(String),
4491 Invariant(String),
4492 Unavailable(String),
4493 ResourceExhausted(String),
4494 ConfigurationTransition {
4495 state: Box<ConfigurationState>,
4496 },
4497 Contention(String),
4498 WinnerLimitExceeded,
4499 #[cfg(feature = "sql")]
4500 RequestConflict(RequestConflict),
4501 InvalidRequest(String),
4502 #[cfg(feature = "sql")]
4503 InvalidSqlStatement {
4504 statement_index: usize,
4505 message: String,
4506 },
4507 PreconditionFailed(String),
4508 Fatal(String),
4509}
4510
4511impl fmt::Display for NodeError {
4512 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4513 match self {
4514 Self::UnsupportedAckMode(mode) => {
4515 write!(
4516 f,
4517 "ack mode {mode:?} is unsupported without synchronous archive"
4518 )
4519 }
4520 Self::ExecutionProfileMismatch { expected, actual } => write!(
4521 f,
4522 "execution profile mismatch: expected {expected}, got {actual}"
4523 ),
4524 Self::DataRootLocked(path) => {
4525 write!(f, "node data root is already owned: {}", path.display())
4526 }
4527 Self::SnapshotRequired(anchor) => write!(
4528 f,
4529 "snapshot restore required at qlog anchor {}",
4530 anchor.compacted().index()
4531 ),
4532 Self::Storage(message) => write!(f, "node storage failed: {message}"),
4533 Self::Reconciliation(message) => write!(f, "node reconciliation failed: {message}"),
4534 Self::Invariant(message) => write!(f, "node invariant failed: {message}"),
4535 Self::Unavailable(message) => write!(f, "node unavailable: {message}"),
4536 Self::ResourceExhausted(message) => {
4537 write!(f, "node query resources exhausted: {message}")
4538 }
4539 Self::ConfigurationTransition { state } => write!(
4540 f,
4541 "node unavailable during configuration transition: {state:?}"
4542 ),
4543 Self::Contention(message) => write!(f, "node contention: {message}"),
4544 Self::WinnerLimitExceeded => write!(f, "foreign winner retry limit exceeded"),
4545 #[cfg(feature = "sql")]
4546 Self::RequestConflict(conflict) => conflict.fmt(f),
4547 Self::InvalidRequest(message) => write!(f, "invalid request: {message}"),
4548 #[cfg(feature = "sql")]
4549 Self::InvalidSqlStatement {
4550 statement_index,
4551 message,
4552 } => write!(
4553 f,
4554 "invalid SQL statement at index {statement_index}: {message}"
4555 ),
4556 Self::PreconditionFailed(message) => write!(f, "precondition failed: {message}"),
4557 Self::Fatal(message) => write!(f, "node is fatally unavailable: {message}"),
4558 }
4559 }
4560}
4561
4562impl std::error::Error for NodeError {}
4563
4564impl NodeError {
4565 pub fn classification(&self) -> ErrorClassification {
4566 let (code, retryable) = match self {
4567 Self::InvalidRequest(_) => ("invalid_request", false),
4568 #[cfg(feature = "sql")]
4569 Self::InvalidSqlStatement { .. } => ("invalid_request", false),
4570 #[cfg(feature = "sql")]
4571 Self::RequestConflict(_) => ("request_conflict", false),
4572 Self::PreconditionFailed(_) => ("precondition_failed", false),
4573 Self::SnapshotRequired(_) => ("snapshot_required", false),
4574 Self::Unavailable(_) => ("unavailable", true),
4575 Self::ResourceExhausted(_) => ("resource_exhausted", true),
4576 Self::ConfigurationTransition { .. } => ("configuration_transition", true),
4577 Self::Contention(_) => ("contention", true),
4578 Self::WinnerLimitExceeded => ("winner_limit_exceeded", true),
4579 Self::DataRootLocked(_) => ("data_root_locked", false),
4580 Self::UnsupportedAckMode(_) => ("unsupported_ack_mode", false),
4581 Self::ExecutionProfileMismatch { .. } => ("execution_profile_mismatch", false),
4582 Self::Storage(_) => ("storage_error", false),
4583 Self::Reconciliation(_) => ("reconciliation_error", false),
4584 Self::Invariant(_) => ("invariant_violation", false),
4585 Self::Fatal(_) => ("fatal", false),
4586 };
4587 ErrorClassification::from_server_code(code, retryable)
4588 }
4589}
4590
4591pub type RuntimeError = NodeError;
4592
4593#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4594#[serde(rename_all = "snake_case")]
4595pub enum RuntimeConfigurationStatus {
4596 Active,
4597 Stopped,
4598 AwaitingActivation,
4599}
4600
4601#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4602pub struct NodeStatus {
4603 pub ready: bool,
4604 pub configuration_status: RuntimeConfigurationStatus,
4605 pub configuration_state: ConfigurationState,
4606 pub stop_anchor: Option<rhiza_core::LogAnchor>,
4607 pub active_config_id: u64,
4608 pub active_membership_digest: LogHash,
4609}
4610
4611#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4612#[serde(deny_unknown_fields)]
4613pub struct StopInformation {
4614 pub entry: LogEntry,
4615 pub proof: DecisionProof,
4616}
4617
4618#[cfg(feature = "sql")]
4619#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4620#[serde(deny_unknown_fields)]
4621pub struct WriteRequest {
4622 pub request_id: String,
4623 pub key: String,
4624 pub value: String,
4625}
4626
4627#[cfg(feature = "sql")]
4628#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4629pub struct WriteResponse {
4630 pub applied_index: LogIndex,
4631 pub hash: LogHash,
4632}
4633
4634#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4635#[serde(deny_unknown_fields)]
4636pub struct ClientErrorResponse {
4637 pub code: String,
4638 pub retryable: bool,
4639 pub message: String,
4640 #[serde(skip_serializing_if = "Option::is_none")]
4641 pub statement_index: Option<usize>,
4642}
4643
4644#[cfg(feature = "sql")]
4645#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4646#[serde(deny_unknown_fields)]
4647pub struct ReadRequest {
4648 pub key: String,
4649 pub consistency: Option<ReadConsistency>,
4650}
4651
4652#[cfg(feature = "sql")]
4653#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4654pub struct ReadResponse {
4655 pub value: Option<String>,
4656 pub applied_index: LogIndex,
4657 pub hash: LogHash,
4658}
4659
4660#[cfg(feature = "sql")]
4661#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4662#[serde(deny_unknown_fields)]
4663pub struct SqlExecuteRequest {
4664 pub request_id: String,
4665 pub statements: Vec<SqlStatement>,
4666}
4667
4668#[cfg(feature = "sql")]
4669#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4670#[serde(deny_unknown_fields)]
4671pub struct SqlExecuteResponse {
4672 pub applied_index: LogIndex,
4673 pub hash: LogHash,
4674 pub results: Vec<SqlStatementResult>,
4675}
4676
4677#[cfg(feature = "sql")]
4678impl From<WriteResponse> for SqlExecuteResponse {
4679 fn from(response: WriteResponse) -> Self {
4680 sql_execute_response(response, None)
4681 }
4682}
4683
4684#[cfg(feature = "sql")]
4685fn sql_execute_response(
4686 response: WriteResponse,
4687 result: Option<SqlCommandResult>,
4688) -> SqlExecuteResponse {
4689 let results = result
4690 .map(|result| {
4691 result
4692 .statement_results
4693 .into_iter()
4694 .enumerate()
4695 .map(|(statement_index, result)| SqlStatementResult {
4696 statement_index,
4697 rows_affected: result.rows_affected,
4698 returning: result.returning,
4699 })
4700 .collect()
4701 })
4702 .unwrap_or_default();
4703 SqlExecuteResponse {
4704 applied_index: response.applied_index,
4705 hash: response.hash,
4706 results,
4707 }
4708}
4709
4710#[cfg(feature = "sql")]
4711#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4712pub struct SqlStatementResult {
4713 pub statement_index: usize,
4714 pub rows_affected: u64,
4715 #[serde(skip_serializing_if = "Option::is_none")]
4716 pub returning: Option<SqlQueryResult>,
4717}
4718
4719#[cfg(feature = "sql")]
4720#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4721#[serde(deny_unknown_fields)]
4722pub struct SqlQueryRequest {
4723 pub statement: SqlStatement,
4724 pub consistency: Option<ReadConsistency>,
4725 pub max_rows: Option<u32>,
4726}
4727
4728#[cfg(feature = "sql")]
4729#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4730pub struct SqlQueryResponse {
4731 pub columns: Vec<String>,
4732 pub rows: Vec<Vec<SqlValue>>,
4733 pub applied_index: LogIndex,
4734 pub hash: LogHash,
4735}
4736
4737#[cfg(feature = "graph")]
4738#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4739#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4740pub enum GraphValueDto {
4741 Null,
4742 Bool(bool),
4743 I64(i64),
4744 U64(u64),
4745 F64(f64),
4746 String(String),
4747 Bytes(String),
4748}
4749
4750#[cfg(feature = "graph")]
4751#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4752#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4753pub enum GraphQueryParameterDto {
4754 Null,
4755 Bool(bool),
4756 I64(i64),
4757 U64(u64),
4758 F64(f64),
4759 String(String),
4760 Bytes(String),
4761 List(Vec<Self>),
4762 Struct(BTreeMap<String, Self>),
4763}
4764
4765#[cfg(feature = "graph")]
4766impl TryFrom<GraphQueryParameterDto> for GraphParameterValue {
4767 type Error = NodeError;
4768
4769 fn try_from(value: GraphQueryParameterDto) -> Result<Self, Self::Error> {
4770 Ok(match value {
4771 GraphQueryParameterDto::Null => Self::Null,
4772 GraphQueryParameterDto::Bool(value) => Self::Bool(value),
4773 GraphQueryParameterDto::I64(value) => Self::I64(value),
4774 GraphQueryParameterDto::U64(value) => Self::U64(value),
4775 GraphQueryParameterDto::F64(value) => Self::F64(
4776 CanonicalF64::new(value)
4777 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?,
4778 ),
4779 GraphQueryParameterDto::String(value) => Self::String(value),
4780 GraphQueryParameterDto::Bytes(value) => {
4781 Self::Bytes(decode_base64("graph parameter bytes", &value)?)
4782 }
4783 GraphQueryParameterDto::List(values) => Self::List(
4784 values
4785 .into_iter()
4786 .map(Self::try_from)
4787 .collect::<Result<_, _>>()?,
4788 ),
4789 GraphQueryParameterDto::Struct(values) => Self::Struct(
4790 values
4791 .into_iter()
4792 .map(|(name, value)| Self::try_from(value).map(|value| (name, value)))
4793 .collect::<Result<_, _>>()?,
4794 ),
4795 })
4796 }
4797}
4798
4799#[cfg(feature = "graph")]
4800#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4801#[serde(deny_unknown_fields)]
4802pub struct GraphQueryStatementDto {
4803 pub cypher: String,
4804 #[serde(default)]
4805 pub parameters: BTreeMap<String, GraphQueryParameterDto>,
4806}
4807
4808#[cfg(feature = "graph")]
4809#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4810#[serde(deny_unknown_fields)]
4811pub struct GraphQueryRequest {
4812 pub statement: GraphQueryStatementDto,
4813 pub consistency: Option<ReadConsistency>,
4814 pub max_rows: Option<u32>,
4815}
4816
4817#[cfg(feature = "graph")]
4818#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4819pub struct GraphInternalIdDto {
4820 pub offset: u64,
4821 pub table_id: u64,
4822}
4823
4824#[cfg(feature = "graph")]
4825impl From<GraphInternalId> for GraphInternalIdDto {
4826 fn from(value: GraphInternalId) -> Self {
4827 Self {
4828 offset: value.offset,
4829 table_id: value.table_id,
4830 }
4831 }
4832}
4833
4834#[cfg(feature = "graph")]
4835#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4836pub struct GraphNamedValueDto {
4837 pub name: String,
4838 pub value: GraphResultValueDto,
4839}
4840
4841#[cfg(feature = "graph")]
4842#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4843pub struct GraphNodeDto {
4844 pub id: GraphInternalIdDto,
4845 pub label: String,
4846 pub properties: Vec<GraphNamedValueDto>,
4847}
4848
4849#[cfg(feature = "graph")]
4850impl From<GraphNode> for GraphNodeDto {
4851 fn from(value: GraphNode) -> Self {
4852 Self {
4853 id: value.id.into(),
4854 label: value.label,
4855 properties: named_graph_values(value.properties),
4856 }
4857 }
4858}
4859
4860#[cfg(feature = "graph")]
4861#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4862pub struct GraphRelDto {
4863 pub src: GraphInternalIdDto,
4864 pub dst: GraphInternalIdDto,
4865 pub label: String,
4866 pub properties: Vec<GraphNamedValueDto>,
4867}
4868
4869#[cfg(feature = "graph")]
4870impl From<GraphRel> for GraphRelDto {
4871 fn from(value: GraphRel) -> Self {
4872 Self {
4873 src: value.src.into(),
4874 dst: value.dst.into(),
4875 label: value.label,
4876 properties: named_graph_values(value.properties),
4877 }
4878 }
4879}
4880
4881#[cfg(feature = "graph")]
4882#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4883pub struct GraphRecursiveRelDto {
4884 pub nodes: Vec<GraphNodeDto>,
4885 pub rels: Vec<GraphRelDto>,
4886}
4887
4888#[cfg(feature = "graph")]
4889#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4890pub struct GraphMapEntryDto {
4891 pub key: GraphResultValueDto,
4892 pub value: GraphResultValueDto,
4893}
4894
4895#[cfg(feature = "graph")]
4896#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4897pub struct GraphNamedLogicalTypeDto {
4898 pub name: String,
4899 pub logical_type: GraphLogicalTypeDto,
4900}
4901
4902#[cfg(feature = "graph")]
4903#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4904pub struct GraphArrayTypeDto {
4905 pub element_type: Box<GraphLogicalTypeDto>,
4906 pub length: u64,
4907}
4908
4909#[cfg(feature = "graph")]
4910#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4911pub struct GraphMapTypeDto {
4912 pub key_type: Box<GraphLogicalTypeDto>,
4913 pub value_type: Box<GraphLogicalTypeDto>,
4914}
4915
4916#[cfg(feature = "graph")]
4917#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4918pub struct GraphDecimalTypeDto {
4919 pub precision: u32,
4920 pub scale: u32,
4921}
4922
4923#[cfg(feature = "graph")]
4924#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4925#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4926pub enum GraphLogicalTypeDto {
4927 Any,
4928 Bool,
4929 Serial,
4930 I64,
4931 I32,
4932 I16,
4933 I8,
4934 U64,
4935 U32,
4936 U16,
4937 U8,
4938 I128,
4939 F64,
4940 F32,
4941 Date,
4942 Interval,
4943 Timestamp,
4944 TimestampTz,
4945 TimestampNs,
4946 TimestampMs,
4947 TimestampSec,
4948 InternalId,
4949 String,
4950 Json,
4951 Bytes,
4952 List(Box<Self>),
4953 Array(GraphArrayTypeDto),
4954 Struct(Vec<GraphNamedLogicalTypeDto>),
4955 Node,
4956 Rel,
4957 RecursiveRel,
4958 Map(GraphMapTypeDto),
4959 Union(Vec<GraphNamedLogicalTypeDto>),
4960 Uuid,
4961 Decimal(GraphDecimalTypeDto),
4962}
4963
4964#[cfg(feature = "graph")]
4965impl From<GraphLogicalType> for GraphLogicalTypeDto {
4966 fn from(value: GraphLogicalType) -> Self {
4967 match value {
4968 GraphLogicalType::Any => Self::Any,
4969 GraphLogicalType::Bool => Self::Bool,
4970 GraphLogicalType::Serial => Self::Serial,
4971 GraphLogicalType::I64 => Self::I64,
4972 GraphLogicalType::I32 => Self::I32,
4973 GraphLogicalType::I16 => Self::I16,
4974 GraphLogicalType::I8 => Self::I8,
4975 GraphLogicalType::U64 => Self::U64,
4976 GraphLogicalType::U32 => Self::U32,
4977 GraphLogicalType::U16 => Self::U16,
4978 GraphLogicalType::U8 => Self::U8,
4979 GraphLogicalType::I128 => Self::I128,
4980 GraphLogicalType::F64 => Self::F64,
4981 GraphLogicalType::F32 => Self::F32,
4982 GraphLogicalType::Date => Self::Date,
4983 GraphLogicalType::Interval => Self::Interval,
4984 GraphLogicalType::Timestamp => Self::Timestamp,
4985 GraphLogicalType::TimestampTz => Self::TimestampTz,
4986 GraphLogicalType::TimestampNs => Self::TimestampNs,
4987 GraphLogicalType::TimestampMs => Self::TimestampMs,
4988 GraphLogicalType::TimestampSec => Self::TimestampSec,
4989 GraphLogicalType::InternalId => Self::InternalId,
4990 GraphLogicalType::String => Self::String,
4991 GraphLogicalType::Json => Self::Json,
4992 GraphLogicalType::Bytes => Self::Bytes,
4993 GraphLogicalType::List(element_type) => Self::List(Box::new((*element_type).into())),
4994 GraphLogicalType::Array {
4995 element_type,
4996 length,
4997 } => Self::Array(GraphArrayTypeDto {
4998 element_type: Box::new((*element_type).into()),
4999 length,
5000 }),
5001 GraphLogicalType::Struct(fields) => Self::Struct(named_graph_logical_types(fields)),
5002 GraphLogicalType::Node => Self::Node,
5003 GraphLogicalType::Rel => Self::Rel,
5004 GraphLogicalType::RecursiveRel => Self::RecursiveRel,
5005 GraphLogicalType::Map {
5006 key_type,
5007 value_type,
5008 } => Self::Map(GraphMapTypeDto {
5009 key_type: Box::new((*key_type).into()),
5010 value_type: Box::new((*value_type).into()),
5011 }),
5012 GraphLogicalType::Union(types) => Self::Union(named_graph_logical_types(types)),
5013 GraphLogicalType::Uuid => Self::Uuid,
5014 GraphLogicalType::Decimal { precision, scale } => {
5015 Self::Decimal(GraphDecimalTypeDto { precision, scale })
5016 }
5017 }
5018 }
5019}
5020
5021#[cfg(feature = "graph")]
5022#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5023pub struct GraphCollectionValueDto {
5024 pub element_type: GraphLogicalTypeDto,
5025 pub values: Vec<GraphResultValueDto>,
5026}
5027
5028#[cfg(feature = "graph")]
5029#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5030pub struct GraphMapValueDto {
5031 pub key_type: GraphLogicalTypeDto,
5032 pub value_type: GraphLogicalTypeDto,
5033 pub entries: Vec<GraphMapEntryDto>,
5034}
5035
5036#[cfg(feature = "graph")]
5037#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5038pub struct GraphUnionValueDto {
5039 pub variants: Vec<GraphNamedLogicalTypeDto>,
5040 pub value: Box<GraphResultValueDto>,
5041}
5042
5043#[cfg(feature = "graph")]
5044#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5045#[serde(tag = "type", content = "value", rename_all = "snake_case")]
5046pub enum GraphResultValueDto {
5047 Null(GraphLogicalTypeDto),
5048 Bool(bool),
5049 I64(i64),
5050 I32(i32),
5051 I16(i16),
5052 I8(i8),
5053 U64(u64),
5054 U32(u32),
5055 U16(u16),
5056 U8(u8),
5057 I128(String),
5058 F64(f64),
5059 F32(String),
5060 Date(String),
5061 Interval(String),
5062 Timestamp(String),
5063 TimestampTz(String),
5064 TimestampNs(String),
5065 TimestampMs(String),
5066 TimestampSec(String),
5067 InternalId(GraphInternalIdDto),
5068 String(String),
5069 Json(String),
5070 Bytes(String),
5071 List(GraphCollectionValueDto),
5072 Array(GraphCollectionValueDto),
5073 Struct(Vec<GraphNamedValueDto>),
5074 Node(GraphNodeDto),
5075 Rel(GraphRelDto),
5076 RecursiveRel(GraphRecursiveRelDto),
5077 Map(GraphMapValueDto),
5078 Union(GraphUnionValueDto),
5079 Uuid(String),
5080 Decimal(String),
5081}
5082
5083#[cfg(feature = "graph")]
5084impl From<GraphResultValue> for GraphResultValueDto {
5085 fn from(value: GraphResultValue) -> Self {
5086 match value {
5087 GraphResultValue::Null(value) => Self::Null(value.into()),
5088 GraphResultValue::Bool(value) => Self::Bool(value),
5089 GraphResultValue::I64(value) => Self::I64(value),
5090 GraphResultValue::I32(value) => Self::I32(value),
5091 GraphResultValue::I16(value) => Self::I16(value),
5092 GraphResultValue::I8(value) => Self::I8(value),
5093 GraphResultValue::U64(value) => Self::U64(value),
5094 GraphResultValue::U32(value) => Self::U32(value),
5095 GraphResultValue::U16(value) => Self::U16(value),
5096 GraphResultValue::U8(value) => Self::U8(value),
5097 GraphResultValue::I128(value) => Self::I128(value),
5098 GraphResultValue::F64(value) => Self::F64(value.get()),
5099 GraphResultValue::F32(value) => Self::F32(value),
5100 GraphResultValue::Date(value) => Self::Date(value),
5101 GraphResultValue::Interval(value) => Self::Interval(value),
5102 GraphResultValue::Timestamp(value) => Self::Timestamp(value),
5103 GraphResultValue::TimestampTz(value) => Self::TimestampTz(value),
5104 GraphResultValue::TimestampNs(value) => Self::TimestampNs(value),
5105 GraphResultValue::TimestampMs(value) => Self::TimestampMs(value),
5106 GraphResultValue::TimestampSec(value) => Self::TimestampSec(value),
5107 GraphResultValue::InternalId(value) => Self::InternalId(value.into()),
5108 GraphResultValue::String(value) => Self::String(value),
5109 GraphResultValue::Json(value) => Self::Json(value),
5110 GraphResultValue::Bytes(value) => Self::Bytes(encode_base64(&value)),
5111 GraphResultValue::List {
5112 element_type,
5113 values,
5114 } => Self::List(GraphCollectionValueDto {
5115 element_type: element_type.into(),
5116 values: values.into_iter().map(Self::from).collect(),
5117 }),
5118 GraphResultValue::Array {
5119 element_type,
5120 values,
5121 } => Self::Array(GraphCollectionValueDto {
5122 element_type: element_type.into(),
5123 values: values.into_iter().map(Self::from).collect(),
5124 }),
5125 GraphResultValue::Struct(values) => Self::Struct(named_graph_values(values)),
5126 GraphResultValue::Node(value) => Self::Node(value.into()),
5127 GraphResultValue::Rel(value) => Self::Rel(value.into()),
5128 GraphResultValue::RecursiveRel { nodes, rels } => {
5129 Self::RecursiveRel(GraphRecursiveRelDto {
5130 nodes: nodes.into_iter().map(Into::into).collect(),
5131 rels: rels.into_iter().map(Into::into).collect(),
5132 })
5133 }
5134 GraphResultValue::Map {
5135 key_type,
5136 value_type,
5137 entries,
5138 } => Self::Map(GraphMapValueDto {
5139 key_type: key_type.into(),
5140 value_type: value_type.into(),
5141 entries: entries
5142 .into_iter()
5143 .map(|(key, value)| GraphMapEntryDto {
5144 key: key.into(),
5145 value: value.into(),
5146 })
5147 .collect(),
5148 }),
5149 GraphResultValue::Union { variants, value } => Self::Union(GraphUnionValueDto {
5150 variants: named_graph_logical_types(variants),
5151 value: Box::new(Self::from(*value)),
5152 }),
5153 GraphResultValue::Uuid(value) => Self::Uuid(value),
5154 GraphResultValue::Decimal(value) => Self::Decimal(value),
5155 }
5156 }
5157}
5158
5159#[cfg(feature = "graph")]
5160fn named_graph_values(values: Vec<(String, GraphResultValue)>) -> Vec<GraphNamedValueDto> {
5161 values
5162 .into_iter()
5163 .map(|(name, value)| GraphNamedValueDto {
5164 name,
5165 value: value.into(),
5166 })
5167 .collect()
5168}
5169
5170#[cfg(feature = "graph")]
5171fn named_graph_logical_types(
5172 values: Vec<(String, GraphLogicalType)>,
5173) -> Vec<GraphNamedLogicalTypeDto> {
5174 values
5175 .into_iter()
5176 .map(|(name, logical_type)| GraphNamedLogicalTypeDto {
5177 name,
5178 logical_type: logical_type.into(),
5179 })
5180 .collect()
5181}
5182
5183#[cfg(feature = "graph")]
5184#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5185pub struct GraphColumnDto {
5186 pub name: String,
5187 pub logical_type: GraphLogicalTypeDto,
5188}
5189
5190#[cfg(feature = "graph")]
5191impl From<GraphColumn> for GraphColumnDto {
5192 fn from(value: GraphColumn) -> Self {
5193 Self {
5194 name: value.name,
5195 logical_type: value.logical_type.into(),
5196 }
5197 }
5198}
5199
5200#[cfg(feature = "graph")]
5201#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5202pub struct GraphQueryResponse {
5203 pub columns: Vec<GraphColumnDto>,
5204 pub rows: Vec<Vec<GraphResultValueDto>>,
5205 pub applied_index: LogIndex,
5206 pub hash: LogHash,
5207}
5208
5209#[cfg(feature = "graph")]
5210impl From<GraphQueryResult> for GraphQueryResponse {
5211 fn from(value: GraphQueryResult) -> Self {
5212 Self {
5213 columns: value.columns.into_iter().map(Into::into).collect(),
5214 rows: value
5215 .rows
5216 .into_iter()
5217 .map(|row| row.into_iter().map(Into::into).collect())
5218 .collect(),
5219 applied_index: value.applied_index,
5220 hash: value.hash,
5221 }
5222 }
5223}
5224
5225#[cfg(feature = "graph")]
5226#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5227#[serde(deny_unknown_fields)]
5228pub struct GraphPutDocumentRequest {
5229 pub request_id: String,
5230 pub id: String,
5231 pub value: GraphValueDto,
5232}
5233
5234#[cfg(feature = "graph")]
5235#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5236#[serde(deny_unknown_fields)]
5237pub struct GraphDeleteDocumentRequest {
5238 pub request_id: String,
5239 pub id: String,
5240}
5241
5242#[cfg(feature = "graph")]
5243#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5244#[serde(deny_unknown_fields)]
5245pub struct GraphGetDocumentRequest {
5246 pub id: String,
5247 pub consistency: Option<ReadConsistency>,
5248}
5249
5250#[cfg(feature = "graph")]
5251#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5252#[serde(tag = "operation", rename_all = "snake_case")]
5253pub enum GraphMutationResultDto {
5254 PutDocument { created: bool },
5255 DeleteDocument { existed: bool },
5256}
5257
5258#[cfg(feature = "graph")]
5259#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5260pub struct GraphMutationResponse {
5261 pub applied_index: LogIndex,
5262 pub hash: LogHash,
5263 pub result: GraphMutationResultDto,
5264}
5265
5266#[cfg(feature = "graph")]
5267#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5268pub struct GraphGetDocumentResponse {
5269 pub value: Option<GraphValueDto>,
5270 pub applied_index: LogIndex,
5271 pub hash: LogHash,
5272}
5273
5274#[cfg(feature = "kv")]
5275#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5276#[serde(deny_unknown_fields)]
5277pub struct KvPutRequest {
5278 pub request_id: String,
5279 pub key: String,
5280 pub value: String,
5281}
5282
5283#[cfg(feature = "kv")]
5284#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5285#[serde(deny_unknown_fields)]
5286pub struct KvDeleteRequest {
5287 pub request_id: String,
5288 pub key: String,
5289}
5290
5291#[cfg(feature = "kv")]
5292#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5293#[serde(deny_unknown_fields)]
5294pub struct KvGetRequest {
5295 pub key: String,
5296 pub consistency: Option<ReadConsistency>,
5297}
5298
5299#[cfg(feature = "kv")]
5300#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5301#[serde(deny_unknown_fields)]
5302pub struct KvScanRequest {
5303 pub start: Option<String>,
5304 pub end: Option<String>,
5305 pub prefix: Option<String>,
5306 pub cursor: Option<String>,
5307 pub limit: Option<usize>,
5308 pub consistency: Option<ReadConsistency>,
5309}
5310
5311#[cfg(feature = "kv")]
5312#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5313#[serde(tag = "operation", rename_all = "snake_case")]
5314pub enum KvMutationResultDto {
5315 Put { replaced: bool },
5316 Delete { existed: bool },
5317}
5318
5319#[cfg(feature = "kv")]
5320#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5321pub struct KvMutationResponse {
5322 pub applied_index: LogIndex,
5323 pub hash: LogHash,
5324 pub result: KvMutationResultDto,
5325}
5326
5327#[cfg(feature = "kv")]
5328#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5329pub struct KvGetResponse {
5330 pub value: Option<String>,
5331 pub applied_index: LogIndex,
5332 pub hash: LogHash,
5333}
5334
5335#[cfg(feature = "kv")]
5336#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5337pub struct KvScanEntryDto {
5338 pub key: String,
5339 pub value: String,
5340}
5341
5342#[cfg(feature = "kv")]
5343#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5344pub struct KvScanResponse {
5345 pub entries: Vec<KvScanEntryDto>,
5346 pub next_cursor: Option<String>,
5347 pub applied_index: LogIndex,
5348 pub hash: LogHash,
5349}
5350
5351#[cfg(feature = "graph")]
5352fn graph_mutation_response(outcome: GraphMutationOutcome) -> GraphMutationResponse {
5353 GraphMutationResponse {
5354 applied_index: outcome.applied_index(),
5355 hash: outcome.hash(),
5356 result: match outcome.result() {
5357 GraphCommandResultV1::PutDocument { created } => {
5358 GraphMutationResultDto::PutDocument { created: *created }
5359 }
5360 GraphCommandResultV1::DeleteDocument { existed } => {
5361 GraphMutationResultDto::DeleteDocument { existed: *existed }
5362 }
5363 },
5364 }
5365}
5366
5367#[cfg(feature = "kv")]
5368fn kv_mutation_response(outcome: KvMutationOutcome) -> KvMutationResponse {
5369 KvMutationResponse {
5370 applied_index: outcome.applied_index(),
5371 hash: outcome.hash(),
5372 result: match outcome.result() {
5373 KvCommandResultV1::Put { replaced } => KvMutationResultDto::Put {
5374 replaced: *replaced,
5375 },
5376 KvCommandResultV1::Delete { existed } => {
5377 KvMutationResultDto::Delete { existed: *existed }
5378 }
5379 },
5380 }
5381}
5382
5383#[cfg(feature = "kv")]
5384fn validate_kv_scan_required_index(
5385 result: &KvScanResult,
5386 required_index: Option<LogIndex>,
5387) -> Result<(), NodeError> {
5388 let applied_index = result.tip().applied_index();
5389 if required_index.is_some_and(|required| applied_index < required) {
5390 return Err(NodeError::Unavailable(format!(
5391 "local applied index {applied_index} has not reached {}",
5392 required_index.expect("checked above")
5393 )));
5394 }
5395 Ok(())
5396}
5397
5398#[cfg(feature = "graph")]
5399impl TryFrom<GraphValueDto> for GraphValueV1 {
5400 type Error = NodeError;
5401
5402 fn try_from(value: GraphValueDto) -> Result<Self, Self::Error> {
5403 match value {
5404 GraphValueDto::Null => Ok(Self::Null),
5405 GraphValueDto::Bool(value) => Ok(Self::Bool(value)),
5406 GraphValueDto::I64(value) => Ok(Self::I64(value)),
5407 GraphValueDto::U64(value) => Ok(Self::U64(value)),
5408 GraphValueDto::F64(value) => {
5409 Self::from_f64(value).map_err(|error| NodeError::InvalidRequest(error.to_string()))
5410 }
5411 GraphValueDto::String(value) => Ok(Self::String(value)),
5412 GraphValueDto::Bytes(value) => decode_base64("value", &value).map(Self::Bytes),
5413 }
5414 }
5415}
5416
5417#[cfg(feature = "graph")]
5418impl From<GraphValueV1> for GraphValueDto {
5419 fn from(value: GraphValueV1) -> Self {
5420 match value {
5421 GraphValueV1::Null => Self::Null,
5422 GraphValueV1::Bool(value) => Self::Bool(value),
5423 GraphValueV1::I64(value) => Self::I64(value),
5424 GraphValueV1::U64(value) => Self::U64(value),
5425 GraphValueV1::F64(value) => Self::F64(value.get()),
5426 GraphValueV1::String(value) => Self::String(value),
5427 GraphValueV1::Bytes(value) => Self::Bytes(encode_base64(&value)),
5428 }
5429 }
5430}
5431
5432#[cfg(any(feature = "graph", feature = "kv"))]
5433fn encode_base64(bytes: &[u8]) -> String {
5434 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
5435 let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4);
5436 for chunk in bytes.chunks(3) {
5437 let first = chunk[0];
5438 let second = chunk.get(1).copied().unwrap_or(0);
5439 let third = chunk.get(2).copied().unwrap_or(0);
5440 encoded.push(ALPHABET[usize::from(first >> 2)] as char);
5441 encoded.push(ALPHABET[usize::from(((first & 0x03) << 4) | (second >> 4))] as char);
5442 if chunk.len() > 1 {
5443 encoded.push(ALPHABET[usize::from(((second & 0x0f) << 2) | (third >> 6))] as char);
5444 } else {
5445 encoded.push('=');
5446 }
5447 if chunk.len() > 2 {
5448 encoded.push(ALPHABET[usize::from(third & 0x3f)] as char);
5449 } else {
5450 encoded.push('=');
5451 }
5452 }
5453 encoded
5454}
5455
5456#[cfg(any(feature = "graph", feature = "kv"))]
5457fn decode_base64(field: &str, encoded: &str) -> Result<Vec<u8>, NodeError> {
5458 fn sextet(byte: u8) -> Option<u8> {
5459 match byte {
5460 b'A'..=b'Z' => Some(byte - b'A'),
5461 b'a'..=b'z' => Some(byte - b'a' + 26),
5462 b'0'..=b'9' => Some(byte - b'0' + 52),
5463 b'+' => Some(62),
5464 b'/' => Some(63),
5465 _ => None,
5466 }
5467 }
5468
5469 let bytes = encoded.as_bytes();
5470 if !bytes.len().is_multiple_of(4) {
5471 return Err(NodeError::InvalidRequest(format!(
5472 "{field} must be canonical padded base64"
5473 )));
5474 }
5475 let mut decoded = Vec::with_capacity(bytes.len() / 4 * 3);
5476 for (chunk_index, chunk) in bytes.chunks_exact(4).enumerate() {
5477 let last = chunk_index + 1 == bytes.len() / 4;
5478 let first = sextet(chunk[0]);
5479 let second = sextet(chunk[1]);
5480 let third = (chunk[2] != b'=').then(|| sextet(chunk[2])).flatten();
5481 let fourth = (chunk[3] != b'=').then(|| sextet(chunk[3])).flatten();
5482 let has_padding = chunk[2] == b'=' || chunk[3] == b'=';
5483 if first.is_none()
5484 || second.is_none()
5485 || (chunk[2] != b'=' && third.is_none())
5486 || (chunk[3] != b'=' && fourth.is_none())
5487 || (!last && has_padding)
5488 || (chunk[2] == b'=' && chunk[3] != b'=')
5489 {
5490 return Err(NodeError::InvalidRequest(format!(
5491 "{field} must be canonical padded base64"
5492 )));
5493 }
5494 let first = first.unwrap();
5495 let second = second.unwrap();
5496 decoded.push((first << 2) | (second >> 4));
5497 if let Some(third) = third {
5498 decoded.push((second << 4) | (third >> 2));
5499 if let Some(fourth) = fourth {
5500 decoded.push((third << 6) | fourth);
5501 } else if third & 0x03 != 0 {
5502 return Err(NodeError::InvalidRequest(format!(
5503 "{field} must be canonical padded base64"
5504 )));
5505 }
5506 } else if second & 0x0f != 0 {
5507 return Err(NodeError::InvalidRequest(format!(
5508 "{field} must be canonical padded base64"
5509 )));
5510 }
5511 }
5512 Ok(decoded)
5513}
5514
5515enum Materializer {
5516 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5517 Unavailable,
5518 #[cfg(feature = "sql")]
5519 Sql(Box<SqliteStateMachine>),
5520 #[cfg(feature = "graph")]
5521 Graph(Arc<LadybugStateMachine>),
5522 #[cfg(feature = "kv")]
5523 Kv(Arc<RedbStateMachine>),
5524}
5525
5526#[cfg(any(feature = "sql", feature = "kv"))]
5527fn quarantine_materializer(data_dir: &Path, directory: &str) -> Result<(), NodeError> {
5528 static SEQUENCE: AtomicUsize = AtomicUsize::new(0);
5529 let source = data_dir.join(directory);
5530 if !source.exists() {
5531 return Ok(());
5532 }
5533 loop {
5534 let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
5535 let target = data_dir.join(format!(
5536 "{directory}.quarantine-{}-{sequence}",
5537 std::process::id()
5538 ));
5539 match fs::rename(&source, target) {
5540 Ok(()) => return Ok(()),
5541 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
5542 Err(error) => return Err(NodeError::Storage(error.to_string())),
5543 }
5544 }
5545}
5546
5547#[cfg(feature = "sql")]
5548struct SqlMaterializerGuard<'a>(MutexGuard<'a, Materializer>);
5549
5550#[cfg(feature = "sql")]
5551impl std::ops::Deref for SqlMaterializerGuard<'_> {
5552 type Target = SqliteStateMachine;
5553
5554 fn deref(&self) -> &Self::Target {
5555 match &*self.0 {
5556 Materializer::Sql(state) => state,
5557 #[cfg(feature = "graph")]
5558 Materializer::Graph(_) => unreachable!("SQL guard validated the materializer profile"),
5559 #[cfg(feature = "kv")]
5560 Materializer::Kv(_) => unreachable!("SQL guard validated the materializer profile"),
5561 }
5562 }
5563}
5564
5565impl Materializer {
5566 fn close_for_handoff(self) -> Result<(), String> {
5567 match self {
5568 #[cfg(feature = "sql")]
5569 Self::Sql(state) => state.close_for_handoff().map_err(|error| error.to_string()),
5570 #[cfg(feature = "graph")]
5571 Self::Graph(_) => Ok(()),
5572 #[cfg(feature = "kv")]
5573 Self::Kv(_) => Ok(()),
5574 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5575 Self::Unavailable => Ok(()),
5576 }
5577 }
5578
5579 fn ensure_profile_available(profile: ExecutionProfile) -> Result<(), NodeError> {
5580 if execution_profile_compiled(profile) {
5581 Ok(())
5582 } else {
5583 Err(NodeError::Unavailable(format!(
5584 "{} execution profile is not compiled in",
5585 profile.as_str()
5586 )))
5587 }
5588 }
5589
5590 #[cfg_attr(not(feature = "sql"), allow(unused_variables))]
5591 fn open(
5592 config: &NodeConfig,
5593 configuration_state: &ConfigurationState,
5594 recovery_anchor: Option<&RecoveryAnchor>,
5595 ) -> Result<Self, NodeError> {
5596 match config.execution_profile() {
5597 ExecutionProfile::Sqlite => {
5598 #[cfg(feature = "sql")]
5599 {
5600 let path = config.data_dir().join("sqlite/db.sqlite");
5601 let open = || {
5602 rhiza_sql::cleanup_empty_wal_sidecars_after_owner_fence(&path)?;
5603 SqliteStateMachine::open_with_configuration(
5604 &path,
5605 config.cluster_id(),
5606 config.node_id(),
5607 config.epoch(),
5608 configuration_state.clone(),
5609 )
5610 };
5611 let state = match open() {
5612 Ok(state) => state,
5613 Err(open_error) => match recovery_anchor {
5614 Some(anchor) => {
5615 eprintln!(
5616 "SQL materializer strict reopen failed before snapshot recovery: {open_error}"
5617 );
5618 return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())));
5619 }
5620 None => {
5621 quarantine_materializer(config.data_dir(), "sqlite")?;
5622 open().map_err(|error| NodeError::Storage(error.to_string()))?
5623 }
5624 },
5625 };
5626 Ok(Self::Sql(Box::new(state)))
5627 }
5628 #[cfg(not(feature = "sql"))]
5629 Err(NodeError::Unavailable(
5630 "sql execution profile is not compiled in".into(),
5631 ))
5632 }
5633 ExecutionProfile::Graph => {
5634 #[cfg(feature = "graph")]
5635 {
5636 LadybugStateMachine::open(
5637 config.data_dir().join("ladybug/graph.lbug"),
5638 config.cluster_id(),
5639 config.node_id(),
5640 config.epoch(),
5641 configuration_state.config_id(),
5642 )
5643 .map(Arc::new)
5644 .map(Self::Graph)
5645 .map_err(|error| NodeError::Storage(error.to_string()))
5646 }
5647 #[cfg(not(feature = "graph"))]
5648 Err(NodeError::Unavailable(
5649 "graph execution profile is not compiled in".into(),
5650 ))
5651 }
5652 ExecutionProfile::Kv => {
5653 #[cfg(feature = "kv")]
5654 {
5655 let open = || {
5656 RedbStateMachine::open(
5657 config.data_dir().join("kv/data.redb"),
5658 config.cluster_id(),
5659 config.node_id(),
5660 config.epoch(),
5661 configuration_state.config_id(),
5662 )
5663 };
5664 let state = match open() {
5665 Ok(state) => state,
5666 Err(_) => match recovery_anchor {
5667 Some(anchor) => {
5668 return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())))
5669 }
5670 _ => {
5671 quarantine_materializer(config.data_dir(), "kv")?;
5672 open().map_err(|error| NodeError::Storage(error.to_string()))?
5673 }
5674 },
5675 };
5676 Ok(Self::Kv(Arc::new(state)))
5677 }
5678 #[cfg(not(feature = "kv"))]
5679 Err(NodeError::Unavailable(
5680 "kv execution profile is not compiled in".into(),
5681 ))
5682 }
5683 }
5684 }
5685
5686 #[cfg_attr(not(feature = "sql"), allow(unused_variables))]
5687 fn open_detached(
5688 data_dir: &Path,
5689 profile: ExecutionProfile,
5690 cluster_id: &str,
5691 node_id: &str,
5692 epoch: u64,
5693 configuration_state: &ConfigurationState,
5694 ) -> Result<Self, NodeError> {
5695 Self::ensure_profile_available(profile)?;
5696 match profile {
5697 ExecutionProfile::Sqlite => {
5698 #[cfg(feature = "sql")]
5699 {
5700 SqliteStateMachine::open_with_configuration(
5701 data_dir.join("sqlite/db.sqlite"),
5702 cluster_id,
5703 node_id,
5704 epoch,
5705 configuration_state.clone(),
5706 )
5707 .map(Box::new)
5708 .map(Self::Sql)
5709 .map_err(|error| NodeError::Storage(error.to_string()))
5710 }
5711 #[cfg(not(feature = "sql"))]
5712 Err(NodeError::Unavailable(
5713 "sql execution profile is not compiled in".into(),
5714 ))
5715 }
5716 ExecutionProfile::Graph => {
5717 #[cfg(feature = "graph")]
5718 {
5719 LadybugStateMachine::open(
5720 data_dir.join("ladybug/graph.lbug"),
5721 cluster_id,
5722 node_id,
5723 epoch,
5724 configuration_state.config_id(),
5725 )
5726 .map(Arc::new)
5727 .map(Self::Graph)
5728 .map_err(|error| NodeError::Storage(error.to_string()))
5729 }
5730 #[cfg(not(feature = "graph"))]
5731 Err(NodeError::Unavailable(
5732 "graph execution profile is not compiled in".into(),
5733 ))
5734 }
5735 ExecutionProfile::Kv => {
5736 #[cfg(feature = "kv")]
5737 {
5738 RedbStateMachine::open(
5739 data_dir.join("kv/data.redb"),
5740 cluster_id,
5741 node_id,
5742 epoch,
5743 configuration_state.config_id(),
5744 )
5745 .map(Arc::new)
5746 .map(Self::Kv)
5747 .map_err(|error| NodeError::Storage(error.to_string()))
5748 }
5749 #[cfg(not(feature = "kv"))]
5750 Err(NodeError::Unavailable(
5751 "kv execution profile is not compiled in".into(),
5752 ))
5753 }
5754 }
5755 }
5756
5757 fn profile(&self) -> ExecutionProfile {
5758 match self {
5759 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5760 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5761 #[cfg(feature = "sql")]
5762 Self::Sql(_) => ExecutionProfile::Sqlite,
5763 #[cfg(feature = "graph")]
5764 Self::Graph(_) => ExecutionProfile::Graph,
5765 #[cfg(feature = "kv")]
5766 Self::Kv(_) => ExecutionProfile::Kv,
5767 }
5768 }
5769
5770 fn applied_index(&self) -> Result<LogIndex, String> {
5771 match self {
5772 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5773 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5774 #[cfg(feature = "sql")]
5775 Self::Sql(state) => state
5776 .applied_index_value()
5777 .map_err(|error| error.to_string()),
5778 #[cfg(feature = "graph")]
5779 Self::Graph(state) => state.applied_index().map_err(|error| error.to_string()),
5780 #[cfg(feature = "kv")]
5781 Self::Kv(state) => state.applied_index().map_err(|error| error.to_string()),
5782 }
5783 }
5784
5785 fn applied_hash(&self) -> Result<LogHash, String> {
5786 match self {
5787 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5788 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5789 #[cfg(feature = "sql")]
5790 Self::Sql(state) => state
5791 .applied_hash_value()
5792 .map_err(|error| error.to_string()),
5793 #[cfg(feature = "graph")]
5794 Self::Graph(state) => state.applied_hash().map_err(|error| error.to_string()),
5795 #[cfg(feature = "kv")]
5796 Self::Kv(state) => state.applied_hash().map_err(|error| error.to_string()),
5797 }
5798 }
5799
5800 fn applied_tip(&self) -> Result<LogAnchor, String> {
5801 match self {
5802 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5803 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5804 #[cfg(feature = "sql")]
5805 Self::Sql(state) => state
5806 .applied_tip()
5807 .map(|tip| LogAnchor::new(tip.applied_index(), tip.applied_hash()))
5808 .map_err(|error| error.to_string()),
5809 #[cfg(feature = "graph")]
5810 Self::Graph(state) => state.applied_tip().map_err(|error| error.to_string()),
5811 #[cfg(feature = "kv")]
5812 Self::Kv(state) => state.applied_tip().map_err(|error| error.to_string()),
5813 }
5814 }
5815
5816 fn configuration_state(&self) -> Result<Option<ConfigurationState>, String> {
5817 match self {
5818 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5819 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5820 #[cfg(feature = "sql")]
5821 Self::Sql(state) => state
5822 .configuration_state_value()
5823 .map(Some)
5824 .map_err(|error| error.to_string()),
5825 #[cfg(feature = "graph")]
5826 Self::Graph(_) => Ok(None),
5827 #[cfg(feature = "kv")]
5828 Self::Kv(_) => Ok(None),
5829 }
5830 }
5831
5832 fn apply_entry(&self, entry: &LogEntry) -> Result<Option<SqlCommandResult>, String> {
5833 match self {
5834 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5835 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5836 #[cfg(feature = "sql")]
5837 Self::Sql(state) => state
5838 .apply_entry_with_result(entry)
5839 .map(|outcome| outcome.sql_result().cloned())
5840 .map_err(|error| error.to_string()),
5841 #[cfg(feature = "graph")]
5842 Self::Graph(state) => state
5843 .apply_entry(entry)
5844 .map(|_| None)
5845 .map_err(|error| error.to_string()),
5846 #[cfg(feature = "kv")]
5847 Self::Kv(state) => state
5848 .apply_entry(entry)
5849 .map(|_| None)
5850 .map_err(|error| error.to_string()),
5851 }
5852 }
5853}
5854
5855const READ_BARRIER_COALESCE_WINDOW: Duration = Duration::from_micros(50);
5856
5857struct ReadBarrierRounds {
5858 state: Mutex<ReadBarrierRoundsState>,
5859 collection_window: Duration,
5860}
5861
5862struct ReadBarrierRoundsState {
5863 tail: Option<Arc<ReadBarrierRound>>,
5864 next_generation: u64,
5865}
5866
5867struct ReadBarrierRound {
5868 #[cfg(test)]
5869 generation: u64,
5870 collection_deadline: Instant,
5871 predecessor: Mutex<Option<Arc<ReadBarrierRound>>>,
5872 phase: Mutex<ReadBarrierRoundPhase>,
5873 changed: Condvar,
5874}
5875
5876#[derive(Clone)]
5877enum ReadBarrierRoundPhase {
5878 Collecting,
5879 Running,
5880 Complete(Result<LogAnchor, NodeError>),
5881}
5882
5883struct ReadBarrierParticipant {
5884 round: Arc<ReadBarrierRound>,
5885 leader: bool,
5886}
5887
5888struct ReadBarrierPublication {
5889 round: Arc<ReadBarrierRound>,
5890 published: bool,
5891}
5892
5893impl ReadBarrierRounds {
5894 fn new(collection_window: Duration) -> Self {
5895 Self {
5896 state: Mutex::new(ReadBarrierRoundsState {
5897 tail: None,
5898 next_generation: 0,
5899 }),
5900 collection_window,
5901 }
5902 }
5903
5904 fn join(&self) -> Result<ReadBarrierParticipant, NodeError> {
5905 let mut state = self
5906 .state
5907 .lock()
5908 .map_err(|_| NodeError::Invariant("read barrier generation lock is poisoned".into()))?;
5909 if let Some(tail) = state.tail.as_ref() {
5910 let collecting = matches!(
5911 *tail.phase.lock().map_err(|_| {
5912 NodeError::Invariant("read barrier round lock is poisoned".into())
5913 })?,
5914 ReadBarrierRoundPhase::Collecting
5915 );
5916 if collecting {
5917 return Ok(ReadBarrierParticipant {
5918 round: Arc::clone(tail),
5919 leader: false,
5920 });
5921 }
5922 }
5923
5924 let generation = state
5925 .next_generation
5926 .checked_add(1)
5927 .ok_or_else(|| NodeError::Invariant("read barrier generation is exhausted".into()))?;
5928 state.next_generation = generation;
5929 let collection_deadline = Instant::now()
5930 .checked_add(self.collection_window)
5931 .unwrap_or_else(Instant::now);
5932 let round = Arc::new(ReadBarrierRound {
5933 #[cfg(test)]
5934 generation,
5935 collection_deadline,
5936 predecessor: Mutex::new(state.tail.clone()),
5937 phase: Mutex::new(ReadBarrierRoundPhase::Collecting),
5938 changed: Condvar::new(),
5939 });
5940 state.tail = Some(Arc::clone(&round));
5941 Ok(ReadBarrierParticipant {
5942 round,
5943 leader: true,
5944 })
5945 }
5946
5947 fn cancel_waiters(&self) {
5948 let mut round = self.state.lock().ok().and_then(|state| state.tail.clone());
5949 while let Some(current) = round {
5950 if let Ok(_phase) = current.phase.lock() {
5951 current.changed.notify_all();
5952 }
5953 round = current
5954 .predecessor
5955 .lock()
5956 .ok()
5957 .and_then(|predecessor| predecessor.clone());
5958 }
5959 }
5960}
5961
5962impl ReadBarrierRound {
5963 fn wait_complete(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
5964 let mut phase = self
5965 .phase
5966 .lock()
5967 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5968 loop {
5969 if matches!(*phase, ReadBarrierRoundPhase::Complete(_)) {
5970 return Ok(());
5971 }
5972 if cancelled.load(Ordering::Acquire) {
5973 return Err(NodeError::Unavailable(
5974 "read barrier cancelled during shutdown".into(),
5975 ));
5976 }
5977 phase = self
5978 .changed
5979 .wait(phase)
5980 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5981 }
5982 }
5983
5984 fn result(&self, cancelled: &AtomicBool) -> Result<LogAnchor, NodeError> {
5985 let mut phase = self
5986 .phase
5987 .lock()
5988 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5989 loop {
5990 if let ReadBarrierRoundPhase::Complete(result) = &*phase {
5991 return result.clone();
5992 }
5993 if cancelled.load(Ordering::Acquire) {
5994 return Err(NodeError::Unavailable(
5995 "read barrier cancelled during shutdown".into(),
5996 ));
5997 }
5998 phase = self
5999 .changed
6000 .wait(phase)
6001 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
6002 }
6003 }
6004
6005 fn complete(&self, result: Result<LogAnchor, NodeError>) {
6006 if let Ok(mut phase) = self.phase.lock() {
6007 if !matches!(*phase, ReadBarrierRoundPhase::Complete(_)) {
6008 *phase = ReadBarrierRoundPhase::Complete(result);
6009 }
6010 self.changed.notify_all();
6011 } else {
6012 self.changed.notify_all();
6013 }
6014 }
6015}
6016
6017impl ReadBarrierParticipant {
6018 #[cfg(test)]
6019 fn generation(&self) -> u64 {
6020 self.round.generation
6021 }
6022
6023 #[cfg(test)]
6024 fn is_leader(&self) -> bool {
6025 self.leader
6026 }
6027
6028 fn publication(&self) -> Option<ReadBarrierPublication> {
6029 self.leader.then(|| ReadBarrierPublication {
6030 round: Arc::clone(&self.round),
6031 published: false,
6032 })
6033 }
6034
6035 fn wait(&self, cancelled: &AtomicBool) -> Result<LogAnchor, NodeError> {
6036 self.round.result(cancelled)
6037 }
6038}
6039
6040impl ReadBarrierPublication {
6041 fn wait_turn(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
6042 let predecessor = self
6043 .round
6044 .predecessor
6045 .lock()
6046 .map_err(|_| NodeError::Invariant("read barrier predecessor lock is poisoned".into()))?
6047 .clone();
6048 if let Some(predecessor) = predecessor {
6049 predecessor.wait_complete(cancelled)?;
6050 self.round
6051 .predecessor
6052 .lock()
6053 .map_err(|_| {
6054 NodeError::Invariant("read barrier predecessor lock is poisoned".into())
6055 })?
6056 .take();
6057 }
6058
6059 let mut phase = self
6060 .round
6061 .phase
6062 .lock()
6063 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
6064 loop {
6065 if cancelled.load(Ordering::Acquire) {
6066 return Err(NodeError::Unavailable(
6067 "read barrier cancelled during shutdown".into(),
6068 ));
6069 }
6070 if !matches!(*phase, ReadBarrierRoundPhase::Collecting) {
6071 return Err(NodeError::Invariant(
6072 "read barrier leader left the collecting phase early".into(),
6073 ));
6074 }
6075 let now = Instant::now();
6076 if now >= self.round.collection_deadline {
6077 return Ok(());
6078 }
6079 let wait = self
6080 .round
6081 .collection_deadline
6082 .saturating_duration_since(now);
6083 let (next, _) =
6084 self.round.changed.wait_timeout(phase, wait).map_err(|_| {
6085 NodeError::Invariant("read barrier round lock is poisoned".into())
6086 })?;
6087 phase = next;
6088 }
6089 }
6090
6091 fn start(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
6092 let mut phase = self
6093 .round
6094 .phase
6095 .lock()
6096 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
6097 if cancelled.load(Ordering::Acquire) {
6098 return Err(NodeError::Unavailable(
6099 "read barrier cancelled during shutdown".into(),
6100 ));
6101 }
6102 if !matches!(*phase, ReadBarrierRoundPhase::Collecting) {
6103 return Err(NodeError::Invariant(
6104 "read barrier generation cannot start twice".into(),
6105 ));
6106 }
6107 *phase = ReadBarrierRoundPhase::Running;
6108 Ok(())
6109 }
6110
6111 fn publish(&mut self, result: Result<LogAnchor, NodeError>) {
6112 self.round.complete(result);
6113 self.published = true;
6114 }
6115}
6116
6117impl Drop for ReadBarrierPublication {
6118 fn drop(&mut self) {
6119 if !self.published {
6120 self.round.complete(Err(NodeError::Unavailable(
6121 "read barrier generation leader terminated".into(),
6122 )));
6123 }
6124 }
6125}
6126
6127#[cfg(all(test, feature = "kv"))]
6128type KvGroupCommitAfterExecuteHook = Arc<dyn Fn(&NodeRuntime) + Send + Sync>;
6129
6130pub struct NodeRuntime {
6131 config: NodeConfig,
6132 consensus: Arc<ThreeNodeConsensus>,
6133 log_store: FileLogStore,
6134 materializer: Mutex<Materializer>,
6135 commit: Mutex<()>,
6136 #[cfg(feature = "sql")]
6137 sql_group_commit: SqlGroupCommitQueue,
6138 #[cfg(feature = "kv")]
6139 kv_group_commit: KvGroupCommitQueue,
6140 read_barriers: ReadBarrierRounds,
6141 checkpointing: AtomicBool,
6142 operation_cancelled: AtomicBool,
6143 operation_cancelled_notify: tokio::sync::Notify,
6144 ready: AtomicBool,
6145 fatal: AtomicBool,
6146 fatal_reason: Mutex<Option<String>>,
6147 #[cfg(test)]
6148 materialized_tip_checks: AtomicUsize,
6149 #[cfg(all(test, feature = "sql"))]
6150 sql_group_commit_before_execute_hook: Option<Arc<dyn Fn() + Send + Sync>>,
6151 #[cfg(all(test, feature = "kv"))]
6152 kv_group_commit_before_execute_hook: Option<Arc<dyn Fn() + Send + Sync>>,
6153 #[cfg(all(test, feature = "kv"))]
6154 kv_group_commit_after_execute_hook: Option<KvGroupCommitAfterExecuteHook>,
6155 _data_root_lock: fs::File,
6156}
6157
6158#[cfg(feature = "sql")]
6159struct ExecutedPayload {
6160 response: WriteResponse,
6161 sql_result: Option<SqlCommandResult>,
6162}
6163
6164#[cfg(feature = "sql")]
6165trait SqlWritePhaseProfile {
6166 type Mark;
6167
6168 fn mark(&self) -> Self::Mark;
6169 fn commit_lock_acquired(&mut self);
6170 fn add_precheck_classification(&mut self, mark: Self::Mark);
6171 fn add_qwal_prepare(&mut self, mark: Self::Mark);
6172 fn add_consensus_propose(&mut self, mark: Self::Mark);
6173 fn add_local_qlog_mirror_append(&mut self, mark: Self::Mark);
6174 fn add_sql_materializer_apply(&mut self, mark: Self::Mark);
6175 fn record_success(&mut self, batch_member_count: usize);
6176}
6177
6178#[cfg(feature = "sql")]
6179struct DisabledSqlWritePhaseProfile;
6180
6181#[cfg(feature = "sql")]
6182impl SqlWritePhaseProfile for DisabledSqlWritePhaseProfile {
6183 type Mark = ();
6184
6185 #[inline]
6186 fn mark(&self) {}
6187
6188 #[inline]
6189 fn commit_lock_acquired(&mut self) {}
6190
6191 #[inline]
6192 fn add_precheck_classification(&mut self, (): ()) {}
6193
6194 #[inline]
6195 fn add_qwal_prepare(&mut self, (): ()) {}
6196
6197 #[inline]
6198 fn add_consensus_propose(&mut self, (): ()) {}
6199
6200 #[inline]
6201 fn add_local_qlog_mirror_append(&mut self, (): ()) {}
6202
6203 #[inline]
6204 fn add_sql_materializer_apply(&mut self, (): ()) {}
6205
6206 #[inline]
6207 fn record_success(&mut self, _batch_member_count: usize) {}
6208}
6209
6210#[cfg(feature = "sql")]
6211struct EnabledSqlWritePhaseProfile {
6212 observer: SqlWriteProfiler,
6213 service_started: Instant,
6214 commit_lock_wait: Duration,
6215 precheck_classification: Duration,
6216 qwal_prepare: Duration,
6217 consensus_propose: Duration,
6218 local_qlog_mirror_append: Duration,
6219 sql_materializer_apply: Duration,
6220}
6221
6222#[cfg(feature = "sql")]
6223impl EnabledSqlWritePhaseProfile {
6224 fn new(observer: SqlWriteProfiler) -> Self {
6225 Self {
6226 observer,
6227 service_started: Instant::now(),
6228 commit_lock_wait: Duration::ZERO,
6229 precheck_classification: Duration::ZERO,
6230 qwal_prepare: Duration::ZERO,
6231 consensus_propose: Duration::ZERO,
6232 local_qlog_mirror_append: Duration::ZERO,
6233 sql_materializer_apply: Duration::ZERO,
6234 }
6235 }
6236
6237 fn reset(&mut self) {
6238 self.service_started = Instant::now();
6239 self.commit_lock_wait = Duration::ZERO;
6240 self.precheck_classification = Duration::ZERO;
6241 self.qwal_prepare = Duration::ZERO;
6242 self.consensus_propose = Duration::ZERO;
6243 self.local_qlog_mirror_append = Duration::ZERO;
6244 self.sql_materializer_apply = Duration::ZERO;
6245 }
6246}
6247
6248#[cfg(feature = "sql")]
6249impl SqlWritePhaseProfile for EnabledSqlWritePhaseProfile {
6250 type Mark = Instant;
6251
6252 #[inline]
6253 fn mark(&self) -> Instant {
6254 Instant::now()
6255 }
6256
6257 fn commit_lock_acquired(&mut self) {
6258 self.commit_lock_wait = self.service_started.elapsed();
6259 }
6260
6261 fn add_precheck_classification(&mut self, mark: Instant) {
6262 self.precheck_classification += mark.elapsed();
6263 }
6264
6265 fn add_qwal_prepare(&mut self, mark: Instant) {
6266 self.qwal_prepare += mark.elapsed();
6267 }
6268
6269 fn add_consensus_propose(&mut self, mark: Instant) {
6270 self.consensus_propose += mark.elapsed();
6271 }
6272
6273 fn add_local_qlog_mirror_append(&mut self, mark: Instant) {
6274 self.local_qlog_mirror_append += mark.elapsed();
6275 }
6276
6277 fn add_sql_materializer_apply(&mut self, mark: Instant) {
6278 self.sql_materializer_apply += mark.elapsed();
6279 }
6280
6281 fn record_success(&mut self, batch_member_count: usize) {
6282 let total_service_us = duration_as_u64_micros(self.service_started.elapsed());
6283 let commit_lock_wait_us = duration_as_u64_micros(self.commit_lock_wait);
6284 let precheck_classification_us = duration_as_u64_micros(self.precheck_classification);
6285 let qwal_prepare_us = duration_as_u64_micros(self.qwal_prepare);
6286 let consensus_propose_us = duration_as_u64_micros(self.consensus_propose);
6287 let local_qlog_mirror_append_us = duration_as_u64_micros(self.local_qlog_mirror_append);
6288 let sql_materializer_apply_us = duration_as_u64_micros(self.sql_materializer_apply);
6289 let named_us = commit_lock_wait_us
6290 .saturating_add(precheck_classification_us)
6291 .saturating_add(qwal_prepare_us)
6292 .saturating_add(consensus_propose_us)
6293 .saturating_add(local_qlog_mirror_append_us)
6294 .saturating_add(sql_materializer_apply_us);
6295 self.observer.record(SqlWriteProfileSample {
6296 batch_member_count,
6297 commit_lock_wait_us,
6298 precheck_classification_us,
6299 qwal_prepare_us,
6300 consensus_propose_us,
6301 local_qlog_mirror_append_us,
6302 sql_materializer_apply_us,
6303 response_other_total_us: total_service_us.saturating_sub(named_us),
6304 total_service_us,
6305 });
6306 self.reset();
6307 }
6308}
6309
6310#[cfg(feature = "sql")]
6311fn duration_as_u64_micros(duration: Duration) -> u64 {
6312 u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
6313}
6314
6315#[cfg(feature = "sql")]
6316type CheckedSqlRequest = Result<Option<(RequestOutcome, SqlCommandResult)>, NodeError>;
6317
6318#[cfg(feature = "sql")]
6319#[derive(Clone, Debug, Eq, PartialEq)]
6320pub struct VerifiedSnapshotPublication {
6321 anchor: RecoveryAnchor,
6322}
6323
6324impl fmt::Debug for NodeRuntime {
6325 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6326 f.debug_struct("NodeRuntime")
6327 .field("config", &self.config)
6328 .field("ready", &self.ready.load(Ordering::Acquire))
6329 .field("fatal", &self.fatal.load(Ordering::Acquire))
6330 .finish_non_exhaustive()
6331 }
6332}
6333
6334impl NodeRuntime {
6335 pub fn open(
6336 config: NodeConfig,
6337 consensus: Arc<ThreeNodeConsensus>,
6338 peer_candidates: &[&dyn LogPeer],
6339 ) -> Result<Self, NodeError> {
6340 Self::open_cancellable(config, consensus, peer_candidates, &StartupIoContext::new())
6341 }
6342
6343 pub fn open_cancellable(
6344 config: NodeConfig,
6345 consensus: Arc<ThreeNodeConsensus>,
6346 peer_candidates: &[&dyn LogPeer],
6347 startup: &StartupIoContext,
6348 ) -> Result<Self, NodeError> {
6349 startup.check("runtime configuration validation")?;
6350 if config.ack_mode == AckMode::DrStrong {
6351 return Err(NodeError::UnsupportedAckMode(AckMode::DrStrong));
6352 }
6353 Materializer::ensure_profile_available(config.execution_profile())?;
6354 startup.check("runtime data directory creation")?;
6355 fs::create_dir_all(&config.data_dir)
6356 .map_err(|error| NodeError::Storage(error.to_string()))?;
6357 startup.check("runtime data lock acquisition")?;
6358 let lock_path = config.data_dir.join(".node.lock");
6359 let data_root_lock = fs::OpenOptions::new()
6360 .read(true)
6361 .write(true)
6362 .create(true)
6363 .truncate(false)
6364 .open(&lock_path)
6365 .map_err(|error| NodeError::Storage(error.to_string()))?;
6366 match data_root_lock.try_lock() {
6367 Ok(()) => {}
6368 Err(fs::TryLockError::WouldBlock) => {
6369 return Err(NodeError::DataRootLocked(config.data_dir.clone()));
6370 }
6371 Err(fs::TryLockError::Error(error)) => {
6372 return Err(NodeError::Storage(error.to_string()));
6373 }
6374 }
6375
6376 startup.check("qlog open and recovery")?;
6377 let log_store = FileLogStore::open_with_configuration(
6378 config.data_dir.join("consensus/log"),
6379 &config.cluster_id,
6380 config.epoch,
6381 config.log_initial_configuration.clone(),
6382 )
6383 .map_err(|error| NodeError::Storage(error.to_string()))?;
6384 let persisted_configuration = log_store
6385 .configuration_state()
6386 .map_err(|error| NodeError::Storage(error.to_string()))?;
6387 let recovery_anchor = log_store
6388 .logical_state()
6389 .map_err(|error| NodeError::Storage(error.to_string()))?
6390 .anchor;
6391 startup.check("materializer open and reconciliation")?;
6392 let materializer =
6393 Materializer::open(&config, &persisted_configuration, recovery_anchor.as_ref())?;
6394 reconcile_local_storage(&config, &log_store, &materializer)?;
6395 startup.check("peer recovery")?;
6396 recover_peer_candidates(
6397 &config,
6398 consensus.as_ref(),
6399 &log_store,
6400 &materializer,
6401 peer_candidates,
6402 startup,
6403 )?;
6404 recover_startup_decisions(
6405 &config,
6406 consensus.as_ref(),
6407 &log_store,
6408 &materializer,
6409 startup,
6410 )?;
6411 startup.check("runtime readiness publication")?;
6412
6413 Ok(Self {
6414 #[cfg(feature = "sql")]
6415 sql_group_commit: SqlGroupCommitQueue::new(config.sql_group_commit_queue_capacity),
6416 #[cfg(feature = "kv")]
6417 kv_group_commit: KvGroupCommitQueue::new(),
6418 config,
6419 consensus,
6420 log_store,
6421 materializer: Mutex::new(materializer),
6422 commit: Mutex::new(()),
6423 read_barriers: ReadBarrierRounds::new(READ_BARRIER_COALESCE_WINDOW),
6424 checkpointing: AtomicBool::new(false),
6425 operation_cancelled: AtomicBool::new(false),
6426 operation_cancelled_notify: tokio::sync::Notify::new(),
6427 ready: AtomicBool::new(true),
6428 fatal: AtomicBool::new(false),
6429 fatal_reason: Mutex::new(None),
6430 #[cfg(test)]
6431 materialized_tip_checks: AtomicUsize::new(0),
6432 #[cfg(all(test, feature = "sql"))]
6433 sql_group_commit_before_execute_hook: None,
6434 #[cfg(all(test, feature = "kv"))]
6435 kv_group_commit_before_execute_hook: None,
6436 #[cfg(all(test, feature = "kv"))]
6437 kv_group_commit_after_execute_hook: None,
6438 _data_root_lock: data_root_lock,
6439 })
6440 }
6441
6442 #[cfg(feature = "sql")]
6443 pub fn write(
6444 &self,
6445 request_id: &str,
6446 key: &str,
6447 value: &str,
6448 ) -> Result<WriteResponse, NodeError> {
6449 let payload = canonical_put(request_id, key, value)?;
6450 let _commit = self.lock_commit()?;
6451 self.execute_put_payload_locked(request_id, key, value, payload)
6452 .map(|outcome| outcome.response)
6453 }
6454
6455 #[cfg(feature = "graph")]
6456 pub fn mutate_graph(&self, command: GraphCommandV1) -> Result<GraphMutationOutcome, NodeError> {
6457 let payload = encode_replicated_graph_command(&command)
6458 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6459 if payload.len() > MAX_COMMAND_BYTES {
6460 return Err(NodeError::InvalidRequest(format!(
6461 "command exceeds {MAX_COMMAND_BYTES} bytes"
6462 )));
6463 }
6464 let _commit = self.lock_commit()?;
6465 self.mutate_graph_payload_locked(&command, payload)
6466 }
6467
6468 #[cfg(feature = "graph")]
6474 #[cfg_attr(
6475 all(not(feature = "sql"), not(feature = "kv")),
6476 allow(unreachable_patterns)
6477 )]
6478 pub fn mutate_graph_batch(
6479 &self,
6480 commands: Vec<GraphCommandV1>,
6481 ) -> Result<Vec<Result<GraphMutationOutcome, NodeError>>, NodeError> {
6482 self.require_execution_profile(ExecutionProfile::Graph)?;
6483 validate_typed_batch_len(commands.len())?;
6484 let mut members = Vec::with_capacity(commands.len());
6485 for command in commands {
6486 let payload = encode_replicated_graph_command(&command)
6487 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6488 validate_command_size(&payload)?;
6489 members.push(RuntimeBatchMember {
6490 #[cfg(feature = "sql")]
6491 request_id: command.request_id().to_owned(),
6492 payload,
6493 operation: QueuedOperation::Graph(command),
6494 });
6495 }
6496 Ok(self
6497 .execute_client_batch(members)
6498 .into_iter()
6499 .map(|result| {
6500 result.and_then(|response| match response {
6501 ClientWriteResponse::Graph(outcome) => Ok(outcome),
6502 _ => Err(NodeError::Invariant(
6503 "graph batch returned a response for another profile".into(),
6504 )),
6505 })
6506 })
6507 .collect())
6508 }
6509
6510 #[cfg(feature = "graph")]
6511 fn mutate_graph_payload_locked(
6512 &self,
6513 command: &GraphCommandV1,
6514 payload: Vec<u8>,
6515 ) -> Result<GraphMutationOutcome, NodeError> {
6516 self.ensure_ready()?;
6517 self.ensure_writes_active()?;
6518 if let Some(record) = self.check_graph_request(command.request_id(), &payload)? {
6519 return Ok(GraphMutationOutcome::from_record(record));
6520 }
6521 loop {
6522 let (last_index, last_hash) = self.ensure_materialized_tip()?;
6523 let slot = last_index.checked_add(1).ok_or_else(|| {
6524 self.latch(NodeError::Invariant("qlog index is exhausted".into()))
6525 })?;
6526 let entry = self
6527 .consensus
6528 .propose_at_cancellable(
6529 slot,
6530 last_hash,
6531 Command::new(CommandKind::Deterministic, payload.clone()),
6532 &self.operation_cancelled,
6533 )
6534 .map_err(|error| self.map_consensus_error(error))?;
6535 self.persist_entry(&entry, slot, last_hash)?;
6536 if let Some(record) = self.check_graph_request(command.request_id(), &payload)? {
6537 return Ok(GraphMutationOutcome::from_record(record));
6538 }
6539 if entry.entry_type == EntryType::Command && entry.payload == payload {
6540 return Err(self.latch(NodeError::Invariant(
6541 "committed graph request was not recorded by Ladybug".into(),
6542 )));
6543 }
6544 }
6545 }
6546
6547 #[cfg(feature = "graph")]
6548 pub fn get_graph_document(
6549 &self,
6550 id: &str,
6551 consistency: ReadConsistency,
6552 ) -> Result<GraphReadResponse, NodeError> {
6553 match consistency {
6554 ReadConsistency::Local => self.get_graph_document_local(id, None),
6555 ReadConsistency::AppliedIndex(required) => {
6556 self.get_graph_document_local(id, Some(required))
6557 }
6558 ReadConsistency::ReadBarrier => {
6559 let anchor = self.establish_read_barrier()?;
6560 self.validate_read_barrier_before_snapshot(anchor)?;
6561 let response = self.get_graph_document_local(id, Some(anchor.index()))?;
6562 self.validate_read_barrier_snapshot(
6563 anchor,
6564 LogAnchor::new(response.applied_index, response.hash),
6565 )?;
6566 Ok(response)
6567 }
6568 }
6569 }
6570
6571 #[cfg(feature = "graph")]
6572 pub fn query_graph(
6573 &self,
6574 statement: &str,
6575 parameters: &BTreeMap<String, GraphParameterValue>,
6576 consistency: ReadConsistency,
6577 max_rows: u32,
6578 ) -> Result<GraphQueryResult, NodeError> {
6579 if max_rows == 0 || max_rows > MAX_GRAPH_MAX_ROWS {
6580 return Err(NodeError::InvalidRequest(format!(
6581 "max_rows must be between 1 and {MAX_GRAPH_MAX_ROWS}"
6582 )));
6583 }
6584 match consistency {
6585 ReadConsistency::Local => self.query_graph_local(statement, parameters, None, max_rows),
6586 ReadConsistency::AppliedIndex(required) => {
6587 self.query_graph_local(statement, parameters, Some(required), max_rows)
6588 }
6589 ReadConsistency::ReadBarrier => {
6590 let anchor = self.establish_read_barrier()?;
6591 self.validate_read_barrier_before_snapshot(anchor)?;
6592 let result =
6593 self.query_graph_local(statement, parameters, Some(anchor.index()), max_rows)?;
6594 self.validate_read_barrier_snapshot(
6595 anchor,
6596 LogAnchor::new(result.applied_index, result.hash),
6597 )?;
6598 Ok(result)
6599 }
6600 }
6601 }
6602
6603 #[cfg(feature = "kv")]
6604 pub fn mutate_kv(&self, command: KvCommandV1) -> Result<KvMutationOutcome, NodeError> {
6605 self.mutate_kv_batch(vec![command])?
6606 .into_iter()
6607 .next()
6608 .expect("one-member KV batch returns one result")
6609 }
6610
6611 #[cfg(feature = "kv")]
6617 #[cfg_attr(
6618 all(not(feature = "sql"), not(feature = "graph")),
6619 allow(unreachable_patterns)
6620 )]
6621 pub fn mutate_kv_batch(
6622 &self,
6623 commands: Vec<KvCommandV1>,
6624 ) -> Result<Vec<Result<KvMutationOutcome, NodeError>>, NodeError> {
6625 self.require_execution_profile(ExecutionProfile::Kv)?;
6626 validate_kv_batch_len(commands.len())?;
6627 let mut members = Vec::with_capacity(commands.len());
6628 for command in commands {
6629 let payload = encode_replicated_kv_command(&command)
6630 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6631 validate_command_size(&payload)?;
6632 members.push(RuntimeBatchMember {
6633 #[cfg(feature = "sql")]
6634 request_id: command.request_id().to_owned(),
6635 payload,
6636 operation: QueuedOperation::Kv(command),
6637 });
6638 }
6639 Ok(self
6640 .execute_kv_group_commit(members)?
6641 .into_iter()
6642 .map(|result| {
6643 result.and_then(|response| match response {
6644 ClientWriteResponse::Kv(outcome) => Ok(outcome),
6645 _ => Err(NodeError::Invariant(
6646 "KV batch returned a response for another profile".into(),
6647 )),
6648 })
6649 })
6650 .collect())
6651 }
6652
6653 #[cfg(feature = "kv")]
6654 fn mutate_kv_payload_locked(
6655 &self,
6656 command: &KvCommandV1,
6657 payload: Vec<u8>,
6658 ) -> Result<KvMutationOutcome, NodeError> {
6659 self.ensure_ready()?;
6660 self.ensure_writes_active()?;
6661 if let Some(record) = self.check_kv_request(command.request_id(), &payload)? {
6662 return Ok(KvMutationOutcome::from_record(record));
6663 }
6664 loop {
6665 let (last_index, last_hash) = self.ensure_materialized_tip()?;
6666 let slot = last_index.checked_add(1).ok_or_else(|| {
6667 self.latch(NodeError::Invariant("qlog index is exhausted".into()))
6668 })?;
6669 let entry = self
6670 .consensus
6671 .propose_at_cancellable(
6672 slot,
6673 last_hash,
6674 Command::new(CommandKind::Deterministic, payload.clone()),
6675 &self.operation_cancelled,
6676 )
6677 .map_err(|error| self.map_consensus_error(error))?;
6678 self.persist_entry(&entry, slot, last_hash)?;
6679 if let Some(record) = self.check_kv_request(command.request_id(), &payload)? {
6680 return Ok(KvMutationOutcome::from_record(record));
6681 }
6682 if entry.entry_type == EntryType::Command && entry.payload == payload {
6683 return Err(self.latch(NodeError::Invariant(
6684 "committed KV request was not recorded by redb".into(),
6685 )));
6686 }
6687 }
6688 }
6689
6690 #[cfg(feature = "kv")]
6691 pub fn get_kv(
6692 &self,
6693 key: &[u8],
6694 consistency: ReadConsistency,
6695 ) -> Result<KvReadResponse, NodeError> {
6696 match consistency {
6697 ReadConsistency::Local => self.get_kv_local(key, None),
6698 ReadConsistency::AppliedIndex(required) => self.get_kv_local(key, Some(required)),
6699 ReadConsistency::ReadBarrier => {
6700 let anchor = self.establish_read_barrier()?;
6701 self.validate_read_barrier_before_snapshot(anchor)?;
6702 let response = self.get_kv_local(key, Some(anchor.index()))?;
6703 self.validate_read_barrier_snapshot(
6704 anchor,
6705 LogAnchor::new(response.applied_index, response.hash),
6706 )?;
6707 Ok(response)
6708 }
6709 }
6710 }
6711
6712 #[cfg(feature = "kv")]
6713 pub fn scan_kv_range(
6714 &self,
6715 start: &[u8],
6716 end: Option<&[u8]>,
6717 limit: usize,
6718 cursor: Option<&[u8]>,
6719 consistency: ReadConsistency,
6720 ) -> Result<KvScanResult, NodeError> {
6721 match consistency {
6722 ReadConsistency::Local => self.scan_kv_range_local(start, end, limit, cursor, None),
6723 ReadConsistency::AppliedIndex(required) => {
6724 self.scan_kv_range_local(start, end, limit, cursor, Some(required))
6725 }
6726 ReadConsistency::ReadBarrier => {
6727 let anchor = self.establish_read_barrier()?;
6728 self.validate_read_barrier_before_snapshot(anchor)?;
6729 let result =
6730 self.scan_kv_range_local(start, end, limit, cursor, Some(anchor.index()))?;
6731 self.validate_read_barrier_snapshot(
6732 anchor,
6733 LogAnchor::new(result.tip().applied_index(), result.tip().applied_hash()),
6734 )?;
6735 Ok(result)
6736 }
6737 }
6738 }
6739
6740 #[cfg(feature = "kv")]
6741 pub fn scan_kv_prefix(
6742 &self,
6743 prefix: &[u8],
6744 limit: usize,
6745 cursor: Option<&[u8]>,
6746 consistency: ReadConsistency,
6747 ) -> Result<KvScanResult, NodeError> {
6748 match consistency {
6749 ReadConsistency::Local => self.scan_kv_prefix_local(prefix, limit, cursor, None),
6750 ReadConsistency::AppliedIndex(required) => {
6751 self.scan_kv_prefix_local(prefix, limit, cursor, Some(required))
6752 }
6753 ReadConsistency::ReadBarrier => {
6754 let anchor = self.establish_read_barrier()?;
6755 self.validate_read_barrier_before_snapshot(anchor)?;
6756 let result =
6757 self.scan_kv_prefix_local(prefix, limit, cursor, Some(anchor.index()))?;
6758 self.validate_read_barrier_snapshot(
6759 anchor,
6760 LogAnchor::new(result.tip().applied_index(), result.tip().applied_hash()),
6761 )?;
6762 Ok(result)
6763 }
6764 }
6765 }
6766
6767 #[cfg(feature = "sql")]
6768 pub fn execute_sql(&self, command: SqlCommand) -> Result<WriteResponse, NodeError> {
6769 self.execute_sql_with_results(command)
6770 .map(|response| WriteResponse {
6771 applied_index: response.applied_index,
6772 hash: response.hash,
6773 })
6774 }
6775
6776 #[cfg(feature = "sql")]
6784 pub fn execute_sql_batch(
6785 &self,
6786 commands: Vec<SqlCommand>,
6787 ) -> Result<Vec<Result<SqlExecuteResponse, NodeError>>, NodeError> {
6788 self.require_execution_profile(ExecutionProfile::Sqlite)?;
6789 validate_sql_batch_len(commands.len())?;
6790 let mut members = Vec::with_capacity(commands.len());
6791 let mut aggregate_encoded_bytes = 0_usize;
6792 for command in commands {
6793 validate_field(
6794 "request_id",
6795 &command.request_id,
6796 MAX_REQUEST_ID_BYTES,
6797 false,
6798 )?;
6799 let payload = encode_sql_command_with_index(&command)?;
6800 validate_command_size(&payload)?;
6801 aggregate_encoded_bytes = aggregate_encoded_bytes.saturating_add(payload.len());
6802 if aggregate_encoded_bytes > MAX_COMMAND_BYTES {
6803 return Err(NodeError::ResourceExhausted(format!(
6804 "SQL write batch exceeds {MAX_COMMAND_BYTES} aggregate encoded bytes"
6805 )));
6806 }
6807 members.push(RuntimeBatchMember {
6808 request_id: command.request_id.clone(),
6809 payload,
6810 operation: QueuedOperation::Sql(command),
6811 });
6812 }
6813 let single_statement_batch = members.iter().all(|member| {
6814 matches!(
6815 &member.operation,
6816 QueuedOperation::Sql(command) if command.statements.len() == 1
6817 )
6818 });
6819 let results = if single_statement_batch {
6820 self.execute_sql_group_commit(members)?
6821 } else {
6822 self.execute_client_batch(members)
6823 };
6824 Ok(results
6825 .into_iter()
6826 .map(|result| {
6827 result.and_then(|response| match response {
6828 ClientWriteResponse::Sql(response) => Ok(response),
6829 _ => Err(NodeError::Invariant(
6830 "SQL batch returned a response for another profile".into(),
6831 )),
6832 })
6833 })
6834 .collect())
6835 }
6836
6837 #[cfg(feature = "sql")]
6838 fn execute_sql_with_results(
6839 &self,
6840 command: SqlCommand,
6841 ) -> Result<SqlExecuteResponse, NodeError> {
6842 self.execute_sql_batch(vec![command])?
6843 .into_iter()
6844 .next()
6845 .expect("one-member SQL batch returns one result")
6846 }
6847
6848 #[cfg(feature = "sql")]
6849 fn execute_sql_group_commit(&self, members: Vec<RuntimeBatchMember>) -> SqlGroupCommitResult {
6850 let (job, leader) = self
6851 .sql_group_commit
6852 .enqueue(members, &self.operation_cancelled)?;
6853 if leader {
6854 if let Some(observer) = self.config.sql_write_profiler.clone() {
6855 self.run_sql_group_commit_leader(&mut EnabledSqlWritePhaseProfile::new(observer));
6856 } else {
6857 self.run_sql_group_commit_leader(&mut DisabledSqlWritePhaseProfile);
6858 }
6859 }
6860 job.wait(&self.operation_cancelled)
6861 }
6862
6863 #[cfg(feature = "sql")]
6864 fn run_sql_group_commit_leader<P: SqlWritePhaseProfile>(&self, profile: &mut P) {
6865 let _commit = match self.lock_commit() {
6866 Ok(commit) => commit,
6867 Err(error) => {
6868 self.sql_group_commit.fail_pending(error);
6869 return;
6870 }
6871 };
6872 profile.commit_lock_acquired();
6873
6874 loop {
6875 if !self
6876 .sql_group_commit
6877 .collect_until_full_or_timeout(self.config.writer_batch_window())
6878 {
6879 break;
6880 }
6881 let Some(jobs) = self.sql_group_commit.drain_next_group() else {
6882 break;
6883 };
6884 if let Err(error) = self
6885 .ensure_ready()
6886 .and_then(|_| self.ensure_writes_active())
6887 {
6888 for job in &jobs {
6889 job.publish(Err(error.clone()));
6890 }
6891 self.sql_group_commit.fail_pending(error);
6892 return;
6893 }
6894 let execution = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
6895 #[cfg(test)]
6896 if let Some(hook) = &self.sql_group_commit_before_execute_hook {
6897 hook();
6898 }
6899 self.execute_sql_group_locked(&jobs, profile)
6900 }));
6901 let grouped_results = match execution {
6902 Ok(Ok(grouped_results)) => grouped_results,
6903 Ok(Err(error)) => {
6904 let error = if matches!(error, NodeError::Unavailable(_)) {
6905 error
6906 } else {
6907 self.latch(error)
6908 };
6909 for job in &jobs {
6910 job.publish(Err(error.clone()));
6911 }
6912 self.sql_group_commit.fail_pending(error);
6913 return;
6914 }
6915 Err(_) => {
6916 let error =
6917 self.latch(NodeError::Fatal("SQL group commit leader panicked".into()));
6918 for job in &jobs {
6919 job.publish(Err(error.clone()));
6920 }
6921 self.sql_group_commit.fail_pending(error);
6922 return;
6923 }
6924 };
6925 for (job, results) in jobs.iter().zip(grouped_results) {
6926 job.publish(Ok(results));
6927 }
6928 }
6929 }
6930
6931 #[cfg(feature = "sql")]
6932 fn execute_sql_group_locked<P: SqlWritePhaseProfile>(
6933 &self,
6934 jobs: &[Arc<SqlGroupCommitJob>],
6935 profile: &mut P,
6936 ) -> Result<Vec<Vec<Result<ClientWriteResponse, NodeError>>>, NodeError> {
6937 if self.operation_cancelled.load(Ordering::Acquire) {
6938 return Err(NodeError::Unavailable(
6939 "SQL group commit cancelled during shutdown".into(),
6940 ));
6941 }
6942 let total_members = jobs.iter().map(|job| job.member_count).sum();
6943 if total_members == 0 || total_members > MAX_SQL_WRITE_BATCH_MEMBERS {
6944 return Err(NodeError::Invariant(format!(
6945 "SQL group commit drained {total_members} members outside 1..={MAX_SQL_WRITE_BATCH_MEMBERS}"
6946 )));
6947 }
6948 let mut members = Vec::with_capacity(total_members);
6949 for job in jobs {
6950 let job_members = job.take_members()?;
6951 if job_members.len() != job.member_count {
6952 return Err(NodeError::Invariant(
6953 "SQL group commit job member count changed while queued".into(),
6954 ));
6955 }
6956 members.extend(job_members);
6957 }
6958 let results = self.execute_sql_client_batch_locked(&members, profile);
6959 if self.operation_cancelled.load(Ordering::Acquire) {
6960 return Err(NodeError::Unavailable(
6961 "SQL group commit cancelled during shutdown".into(),
6962 ));
6963 }
6964 if self.is_fatal() {
6965 return Err(NodeError::Fatal(
6966 self.fatal_reason()
6967 .unwrap_or_else(|| "SQL group commit failed fatally".into()),
6968 ));
6969 }
6970 if results.len() != total_members {
6971 return Err(NodeError::Invariant(format!(
6972 "SQL group commit returned {} results for {total_members} members",
6973 results.len()
6974 )));
6975 }
6976 let mut results = results.into_iter();
6977 let grouped = jobs
6978 .iter()
6979 .map(|job| results.by_ref().take(job.member_count).collect::<Vec<_>>())
6980 .collect::<Vec<_>>();
6981 if results.next().is_some() || grouped.iter().map(Vec::len).sum::<usize>() != total_members
6982 {
6983 return Err(NodeError::Invariant(
6984 "SQL group commit result offsets were misaligned".into(),
6985 ));
6986 }
6987 Ok(grouped)
6988 }
6989
6990 #[cfg(feature = "sql")]
6991 fn execute_client_batch(
6992 &self,
6993 members: Vec<RuntimeBatchMember>,
6994 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
6995 if self.config.execution_profile != ExecutionProfile::Sqlite {
6996 return self.execute_profile_client_batch(members);
6997 }
6998 let is_single_statement_sql = members.iter().all(|member| {
6999 matches!(
7000 &member.operation,
7001 QueuedOperation::Sql(command) if command.statements.len() == 1
7002 )
7003 });
7004 if is_single_statement_sql {
7005 if let Some(observer) = self.config.sql_write_profiler.clone() {
7006 let mut profile = EnabledSqlWritePhaseProfile::new(observer);
7007 let _commit = match self.lock_commit() {
7008 Ok(commit) => commit,
7009 Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7010 };
7011 profile.commit_lock_acquired();
7012 if let Err(error) = self
7013 .ensure_ready()
7014 .and_then(|_| self.ensure_writes_active())
7015 {
7016 return members.into_iter().map(|_| Err(error.clone())).collect();
7017 }
7018 return self.execute_sql_client_batch_locked(&members, &mut profile);
7019 }
7020 }
7021 let _commit = match self.lock_commit() {
7022 Ok(commit) => commit,
7023 Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7024 };
7025 if let Err(error) = self
7026 .ensure_ready()
7027 .and_then(|_| self.ensure_writes_active())
7028 {
7029 return members.into_iter().map(|_| Err(error.clone())).collect();
7030 }
7031
7032 if is_single_statement_sql {
7033 return self
7034 .execute_sql_client_batch_locked(&members, &mut DisabledSqlWritePhaseProfile);
7035 }
7036
7037 members
7038 .iter()
7039 .map(|member| self.execute_single_member_locked(member))
7040 .collect()
7041 }
7042
7043 #[cfg(feature = "sql")]
7044 fn execute_sql_client_batch_locked<P: SqlWritePhaseProfile>(
7045 &self,
7046 members: &[RuntimeBatchMember],
7047 profile: &mut P,
7048 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7049 let classification_mark = profile.mark();
7050 let mut results = vec![None; members.len()];
7051 let mut pending = Vec::new();
7052 let mut canonical_by_request: HashMap<String, Vec<usize>> = HashMap::new();
7053 let mut lookup_indices = Vec::with_capacity(members.len());
7054 let mut aliases = vec![None; members.len()];
7055 let mut blocked_by = vec![None; members.len()];
7056
7057 for (index, member) in members.iter().enumerate() {
7058 let QueuedOperation::Sql(command) = &member.operation else {
7059 unreachable!("SQL batch members were validated by the caller");
7060 };
7061 let canonicals = canonical_by_request
7062 .entry(command.request_id.clone())
7063 .or_default();
7064 if let Some(canonical) = canonicals
7065 .iter()
7066 .copied()
7067 .find(|canonical| members[*canonical].payload == member.payload)
7068 {
7069 aliases[index] = Some(canonical);
7070 continue;
7071 }
7072 blocked_by[index] = canonicals.last().copied();
7073 canonicals.push(index);
7074 lookup_indices.push(index);
7075 }
7076
7077 let preflight = self.check_sql_members_bulk(members, &lookup_indices);
7078 let preflight = match preflight {
7079 Ok(preflight) => preflight,
7080 Err(error) => {
7081 profile.add_precheck_classification(classification_mark);
7082 return members.iter().map(|_| Err(error.clone())).collect();
7083 }
7084 };
7085 for (index, lookup) in preflight {
7086 match lookup {
7087 Ok(Some((outcome, sql_result))) => {
7088 results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7089 write_response(outcome),
7090 Some(sql_result),
7091 ))));
7092 }
7093 Ok(None) => pending.push(index),
7094 Err(error) => results[index] = Some(Err(error)),
7095 }
7096 }
7097 profile.add_precheck_classification(classification_mark);
7098
7099 while !pending.is_empty() {
7100 let eligible = pending
7101 .iter()
7102 .copied()
7103 .filter(|index| {
7104 blocked_by[*index].is_none_or(|predecessor| results[predecessor].is_some())
7105 })
7106 .take(MAX_SQL_WRITE_BATCH_MEMBERS)
7107 .collect::<Vec<_>>();
7108 if eligible.is_empty() {
7109 let error = self.latch(NodeError::Invariant(
7110 "SQL writer batch has an unresolved duplicate dependency".into(),
7111 ));
7112 for index in pending.drain(..) {
7113 results[index] = Some(Err(error.clone()));
7114 }
7115 break;
7116 }
7117
7118 let (last_index, last_hash) = match self.ensure_materialized_tip() {
7119 Ok(tip) => tip,
7120 Err(error) => {
7121 for index in pending.drain(..) {
7122 results[index] = Some(Err(error.clone()));
7123 }
7124 break;
7125 }
7126 };
7127 let mut attempt_count = eligible.len();
7128 let (proposal_payload, prepared_results) = loop {
7129 let attempted = &eligible[..attempt_count];
7130 let batch_members = attempted
7131 .iter()
7132 .map(|index| {
7133 let QueuedOperation::Sql(command) = &members[*index].operation else {
7134 unreachable!("SQL batch members were validated above");
7135 };
7136 SqlBatchMember {
7137 command,
7138 request_payload: &members[*index].payload,
7139 }
7140 })
7141 .collect::<Vec<_>>();
7142 let preparation_mark = profile.mark();
7143 let preparation = self.lock_sqlite().and_then(|sqlite| {
7144 sqlite
7145 .prepare_sql_batch_effect(&batch_members, last_index, last_hash)
7146 .map_err(|error| self.map_sqlite_error(error))
7147 });
7148 profile.add_qwal_prepare(preparation_mark);
7149 let preparation = match preparation {
7150 Ok(preparation) => preparation,
7151 Err(NodeError::ResourceExhausted(message)) if attempt_count > 1 => {
7152 attempt_count = attempt_count.div_ceil(2);
7153 let _ = message;
7154 continue;
7155 }
7156 Err(NodeError::ResourceExhausted(message)) => {
7157 results[attempted[0]] = Some(Err(NodeError::ResourceExhausted(message)));
7158 break (None, Vec::new());
7159 }
7160 Err(error) => {
7161 for index in pending.drain(..) {
7162 results[index] = Some(Err(error.clone()));
7163 }
7164 break (None, Vec::new());
7165 }
7166 };
7167 if preparation.results.len() != attempted.len() {
7168 let error = self.latch(NodeError::Invariant(
7169 "SQLite batch preparation returned a misaligned result vector".into(),
7170 ));
7171 for index in pending.drain(..) {
7172 results[index] = Some(Err(error.clone()));
7173 }
7174 break (None, Vec::new());
7175 }
7176
7177 let mut proposed = Vec::new();
7178 for (index, member_result) in attempted.iter().copied().zip(preparation.results) {
7179 match member_result {
7180 Ok(result) => proposed.push((index, result)),
7181 Err(error) => {
7182 results[index] = Some(Err(self.map_sql_batch_member_error(error)))
7183 }
7184 }
7185 }
7186 match preparation.effect {
7187 Some(_) if proposed.is_empty() => {
7188 let error = self.latch(NodeError::Invariant(
7189 "SQLite prepared an effect without a successful SQL member".into(),
7190 ));
7191 for index in pending.drain(..) {
7192 results[index] = Some(Err(error.clone()));
7193 }
7194 break (None, Vec::new());
7195 }
7196 Some(payload) if !payload.starts_with(QWAL_V3_MAGIC) => {
7197 let error = self.latch(NodeError::Invariant(
7198 "SQLite materializer prepared a non-QWAL v3 SQL batch".into(),
7199 ));
7200 for index in pending.drain(..) {
7201 results[index] = Some(Err(error.clone()));
7202 }
7203 break (None, Vec::new());
7204 }
7205 Some(payload) if payload.len() <= MAX_COMMAND_BYTES => {
7206 break (Some(payload), proposed)
7207 }
7208 Some(_) if attempt_count > 1 => {
7209 for index in attempted {
7210 results[*index] = None;
7211 }
7212 attempt_count = attempt_count.div_ceil(2);
7213 }
7214 Some(_) => {
7215 results[attempted[0]] = Some(Err(NodeError::ResourceExhausted(format!(
7216 "SQL effect exceeds {MAX_COMMAND_BYTES} bytes"
7217 ))));
7218 break (None, Vec::new());
7219 }
7220 None if proposed.is_empty() => break (None, Vec::new()),
7221 None => {
7222 let error = self.latch(NodeError::Invariant(
7223 "SQLite omitted the effect for successful SQL members".into(),
7224 ));
7225 for index in pending.drain(..) {
7226 results[index] = Some(Err(error.clone()));
7227 }
7228 break (None, Vec::new());
7229 }
7230 }
7231 };
7232
7233 pending.retain(|index| results[*index].is_none());
7234 let Some(proposal_payload) = proposal_payload else {
7235 continue;
7236 };
7237 let slot = match last_index.checked_add(1) {
7238 Some(slot) => slot,
7239 None => {
7240 let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7241 for index in pending.drain(..) {
7242 results[index] = Some(Err(error.clone()));
7243 }
7244 break;
7245 }
7246 };
7247 let consensus_mark = profile.mark();
7248 let entry = self.consensus.propose_at_cancellable(
7249 slot,
7250 last_hash,
7251 Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7252 &self.operation_cancelled,
7253 );
7254 profile.add_consensus_propose(consensus_mark);
7255 let entry = match entry {
7256 Ok(entry) => entry,
7257 Err(error) => {
7258 let error = self.map_consensus_error(error);
7259 for index in pending.drain(..) {
7260 results[index] = Some(Err(error.clone()));
7261 }
7262 break;
7263 }
7264 };
7265 if let Err(error) = self.persist_sql_entry_profiled(&entry, slot, last_hash, profile) {
7266 for index in pending.drain(..) {
7267 results[index] = Some(Err(error.clone()));
7268 }
7269 break;
7270 }
7271
7272 let exact_winner =
7273 entry.entry_type == EntryType::Command && entry.payload == proposal_payload;
7274 let exact_winner_member_count = exact_winner.then_some(prepared_results.len());
7275 if exact_winner {
7276 for (index, sql_result) in prepared_results {
7277 results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7278 WriteResponse {
7279 applied_index: entry.index,
7280 hash: entry.hash,
7281 },
7282 Some(sql_result),
7283 ))));
7284 }
7285 pending.retain(|index| results[*index].is_none());
7286 }
7287
7288 let current_pending = std::mem::take(&mut pending);
7289 let classification_mark = profile.mark();
7290 let post_commit = self.check_sql_members_bulk(members, ¤t_pending);
7291 let post_commit = match post_commit {
7292 Ok(post_commit) => post_commit,
7293 Err(error) => {
7294 for index in current_pending {
7295 results[index] = Some(Err(error.clone()));
7296 }
7297 profile.add_precheck_classification(classification_mark);
7298 if let Some(batch_member_count) = exact_winner_member_count {
7299 profile.record_success(batch_member_count);
7300 }
7301 break;
7302 }
7303 };
7304 for (index, lookup) in post_commit {
7305 match lookup {
7306 Ok(Some((outcome, sql_result))) => {
7307 results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7308 write_response(outcome),
7309 Some(sql_result),
7310 ))));
7311 }
7312 Ok(None) => pending.push(index),
7313 Err(error) => results[index] = Some(Err(error)),
7314 }
7315 }
7316 profile.add_precheck_classification(classification_mark);
7317 if let Some(batch_member_count) = exact_winner_member_count {
7318 profile.record_success(batch_member_count);
7319 }
7320 }
7321
7322 for (index, canonical) in aliases.into_iter().enumerate() {
7323 if let Some(canonical) = canonical {
7324 results[index] = results[canonical].clone();
7325 }
7326 }
7327 results
7328 .into_iter()
7329 .map(|result| {
7330 result.unwrap_or_else(|| {
7331 Err(self.latch(NodeError::Invariant(
7332 "SQL writer batch omitted a request result".into(),
7333 )))
7334 })
7335 })
7336 .collect()
7337 }
7338
7339 #[cfg(feature = "sql")]
7340 fn check_sql_members_bulk(
7341 &self,
7342 members: &[RuntimeBatchMember],
7343 indices: &[usize],
7344 ) -> Result<Vec<(usize, CheckedSqlRequest)>, NodeError> {
7345 if indices.is_empty() {
7346 return Ok(Vec::new());
7347 }
7348 let mut rounds = Vec::<Vec<usize>>::new();
7349 let mut occurrence_by_request = HashMap::<String, usize>::new();
7350 for index in indices {
7351 let QueuedOperation::Sql(command) = &members[*index].operation else {
7352 return Err(self.latch(NodeError::Invariant(
7353 "non-SQL member reached SQL receipt precheck".into(),
7354 )));
7355 };
7356 let round = occurrence_by_request
7357 .entry(command.request_id.clone())
7358 .or_default();
7359 if rounds.len() == *round {
7360 rounds.push(Vec::new());
7361 }
7362 rounds[*round].push(*index);
7363 *round += 1;
7364 }
7365
7366 let sqlite = self.lock_sqlite()?;
7367 let mut checked = Vec::with_capacity(indices.len());
7368 for round in rounds {
7369 let requests = round
7370 .iter()
7371 .map(|index| {
7372 let QueuedOperation::Sql(command) = &members[*index].operation else {
7373 unreachable!("SQL receipt round contains only SQL members");
7374 };
7375 (
7376 command.request_id.as_str(),
7377 members[*index].payload.as_slice(),
7378 )
7379 })
7380 .collect::<Vec<_>>();
7381 let lookups = sqlite
7382 .check_sql_requests(&requests)
7383 .map_err(|error| self.map_sqlite_error(error))?;
7384 if lookups.len() != round.len() {
7385 return Err(self.latch(NodeError::Invariant(
7386 "SQLite bulk receipt precheck returned a misaligned result vector".into(),
7387 )));
7388 }
7389 for (index, lookup) in round.into_iter().zip(lookups) {
7390 checked.push((
7391 index,
7392 match lookup {
7393 Ok(Some((outcome, Some(sql_result)))) => Ok(Some((outcome, sql_result))),
7394 Ok(Some((_, None))) => Err(self.latch(NodeError::Invariant(
7395 "stored SQL receipt omitted its command result".into(),
7396 ))),
7397 Ok(None) => Ok(None),
7398 Err(error) => Err(self.map_sql_batch_member_error(error)),
7399 },
7400 ));
7401 }
7402 }
7403 Ok(checked)
7404 }
7405
7406 #[cfg(feature = "kv")]
7407 fn execute_kv_group_commit(&self, members: Vec<RuntimeBatchMember>) -> KvGroupCommitResult {
7408 let (job, leader) = self
7409 .kv_group_commit
7410 .enqueue(members, &self.operation_cancelled)?;
7411 if leader {
7412 self.run_kv_group_commit_leader();
7413 }
7414 job.wait(&self.operation_cancelled)
7415 }
7416
7417 #[cfg(feature = "kv")]
7418 fn run_kv_group_commit_leader(&self) {
7419 let _commit = match self.lock_commit() {
7420 Ok(commit) => commit,
7421 Err(error) => {
7422 self.kv_group_commit.fail_pending(error);
7423 return;
7424 }
7425 };
7426
7427 loop {
7428 if !self
7429 .kv_group_commit
7430 .collect_until_full_or_timeout(self.config.writer_batch_window())
7431 {
7432 break;
7433 }
7434 let Some(jobs) = self.kv_group_commit.drain_next_group() else {
7435 break;
7436 };
7437 if let Err(error) = self
7438 .ensure_ready()
7439 .and_then(|_| self.ensure_writes_active())
7440 {
7441 for job in &jobs {
7442 job.publish(Err(error.clone()));
7443 }
7444 self.kv_group_commit.fail_pending(error);
7445 return;
7446 }
7447 let execution = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7448 #[cfg(test)]
7449 if let Some(hook) = &self.kv_group_commit_before_execute_hook {
7450 hook();
7451 }
7452 self.execute_kv_group_locked(&jobs)
7453 }));
7454 let grouped_results = match execution {
7455 Ok(Ok(grouped_results)) => grouped_results,
7456 Ok(Err(error)) => {
7457 let error = if matches!(error, NodeError::Unavailable(_)) {
7458 error
7459 } else {
7460 self.latch(error)
7461 };
7462 for job in &jobs {
7463 job.publish(Err(error.clone()));
7464 }
7465 self.kv_group_commit.fail_pending(error);
7466 return;
7467 }
7468 Err(_) => {
7469 let error =
7470 self.latch(NodeError::Fatal("KV group commit leader panicked".into()));
7471 for job in &jobs {
7472 job.publish(Err(error.clone()));
7473 }
7474 self.kv_group_commit.fail_pending(error);
7475 return;
7476 }
7477 };
7478 for (job, results) in jobs.iter().zip(grouped_results) {
7479 job.publish(Ok(results));
7480 }
7481 }
7482 }
7483
7484 #[cfg(feature = "kv")]
7485 fn execute_kv_group_locked(
7486 &self,
7487 jobs: &[Arc<KvGroupCommitJob>],
7488 ) -> Result<Vec<Vec<Result<ClientWriteResponse, NodeError>>>, NodeError> {
7489 if self.operation_cancelled.load(Ordering::Acquire) {
7490 return Err(NodeError::Unavailable(
7491 "KV group commit cancelled during shutdown".into(),
7492 ));
7493 }
7494 let total_members = jobs.iter().map(|job| job.member_count).sum();
7495 if total_members == 0 || total_members > MAX_KV_GROUP_COMMIT_MEMBERS {
7496 return Err(NodeError::Invariant(format!(
7497 "KV group commit drained {total_members} members outside 1..={MAX_KV_GROUP_COMMIT_MEMBERS}"
7498 )));
7499 }
7500 let mut members = Vec::with_capacity(total_members);
7501 for job in jobs {
7502 let job_members = job.take_members()?;
7503 if job_members.len() != job.member_count {
7504 return Err(NodeError::Invariant(
7505 "KV group commit job member count changed while queued".into(),
7506 ));
7507 }
7508 members.extend(job_members);
7509 }
7510 let results = self.execute_kv_client_batch_locked(&members);
7511 #[cfg(test)]
7512 if let Some(hook) = &self.kv_group_commit_after_execute_hook {
7513 hook(self);
7514 }
7515 if results.len() != total_members {
7516 let error = self.latch(NodeError::Invariant(format!(
7517 "KV group commit returned {} results for {total_members} members",
7518 results.len()
7519 )));
7520 return Ok(jobs
7521 .iter()
7522 .map(|job| vec![Err(error.clone()); job.member_count])
7523 .collect());
7524 }
7525 let mut results = results.into_iter();
7526 let grouped = jobs
7527 .iter()
7528 .map(|job| results.by_ref().take(job.member_count).collect::<Vec<_>>())
7529 .collect::<Vec<_>>();
7530 debug_assert!(results.next().is_none());
7531 debug_assert_eq!(grouped.iter().map(Vec::len).sum::<usize>(), total_members);
7532 Ok(grouped)
7533 }
7534
7535 #[cfg(not(feature = "sql"))]
7536 fn execute_client_batch(
7537 &self,
7538 members: Vec<RuntimeBatchMember>,
7539 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7540 self.execute_profile_client_batch(members)
7541 }
7542
7543 fn execute_profile_client_batch(
7544 &self,
7545 members: Vec<RuntimeBatchMember>,
7546 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7547 #[cfg(feature = "graph")]
7548 if self.config.execution_profile == ExecutionProfile::Graph {
7549 return self.execute_graph_client_batch(members);
7550 }
7551 #[cfg(feature = "kv")]
7552 if self.config.execution_profile == ExecutionProfile::Kv {
7553 return self.execute_kv_client_batch(members);
7554 }
7555 let _commit = match self.lock_commit() {
7556 Ok(commit) => commit,
7557 Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7558 };
7559 if let Err(error) = self
7560 .ensure_ready()
7561 .and_then(|_| self.ensure_writes_active())
7562 {
7563 return members.into_iter().map(|_| Err(error.clone())).collect();
7564 }
7565 members
7566 .iter()
7567 .map(|member| self.execute_profile_member_locked(member))
7568 .collect()
7569 }
7570
7571 #[cfg(feature = "graph")]
7572 #[cfg_attr(
7573 all(not(feature = "sql"), not(feature = "kv")),
7574 allow(irrefutable_let_patterns, unreachable_patterns)
7575 )]
7576 fn execute_graph_client_batch(
7577 &self,
7578 members: Vec<RuntimeBatchMember>,
7579 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7580 let _commit = match self.lock_commit() {
7581 Ok(commit) => commit,
7582 Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7583 };
7584 if let Err(error) = self
7585 .ensure_ready()
7586 .and_then(|_| self.ensure_writes_active())
7587 {
7588 return members.into_iter().map(|_| Err(error.clone())).collect();
7589 }
7590
7591 let mut results = vec![None; members.len()];
7592 let mut pending = Vec::new();
7593 let mut canonical_by_request = HashMap::new();
7594 let mut aliases = vec![None; members.len()];
7595 for (index, member) in members.iter().enumerate() {
7596 let QueuedOperation::Graph(command) = &member.operation else {
7597 results[index] = Some(Err(NodeError::ExecutionProfileMismatch {
7598 expected: ExecutionProfile::Graph,
7599 actual: ExecutionProfile::Sqlite,
7600 }));
7601 continue;
7602 };
7603 match self.check_graph_request(command.request_id(), &member.payload) {
7604 Ok(Some(record)) => {
7605 results[index] = Some(Ok(ClientWriteResponse::Graph(
7606 GraphMutationOutcome::from_record(record),
7607 )));
7608 }
7609 Ok(None) => match classify_pending_request(
7610 &mut canonical_by_request,
7611 &members,
7612 index,
7613 command.request_id(),
7614 ) {
7615 Ok(None) => pending.push(index),
7616 Ok(Some(canonical)) => aliases[index] = Some(canonical),
7617 Err(error) => results[index] = Some(Err(error)),
7618 },
7619 Err(error) => results[index] = Some(Err(error)),
7620 }
7621 }
7622
7623 while !pending.is_empty() {
7624 if pending.len() == 1 {
7625 let index = pending[0];
7626 results[index] = Some(self.execute_profile_member_locked(&members[index]));
7627 break;
7628 }
7629 let commands = pending
7630 .iter()
7631 .map(|index| match &members[*index].operation {
7632 QueuedOperation::Graph(command) => command.clone(),
7633 _ => unreachable!("graph pending members were validated above"),
7634 })
7635 .collect::<Vec<_>>();
7636 let full_payload = match encode_replicated_graph_batch(&commands) {
7637 Ok(payload) => payload,
7638 Err(error) => {
7639 let error = NodeError::InvalidRequest(error.to_string());
7640 for index in pending.drain(..) {
7641 results[index] = Some(Err(error.clone()));
7642 }
7643 break;
7644 }
7645 };
7646 let (proposal_count, proposal_payload) = if full_payload.len() <= MAX_COMMAND_BYTES {
7647 (commands.len(), full_payload)
7648 } else {
7649 let mut prefix = None;
7650 for count in (2..commands.len()).rev() {
7651 let payload = encode_replicated_graph_batch(&commands[..count])
7652 .expect("the validated graph batch prefix remains valid");
7653 if payload.len() <= MAX_COMMAND_BYTES {
7654 prefix = Some((count, payload));
7655 break;
7656 }
7657 }
7658 let Some(prefix) = prefix else {
7659 let index = pending.remove(0);
7660 results[index] = Some(self.execute_profile_member_locked(&members[index]));
7661 continue;
7662 };
7663 prefix
7664 };
7665 let proposed_indices = pending[..proposal_count].to_vec();
7666 let (last_index, last_hash) = match self.ensure_materialized_tip() {
7667 Ok(tip) => tip,
7668 Err(error) => {
7669 for index in pending.drain(..) {
7670 results[index] = Some(Err(error.clone()));
7671 }
7672 break;
7673 }
7674 };
7675 let slot = match last_index.checked_add(1) {
7676 Some(slot) => slot,
7677 None => {
7678 let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7679 for index in pending.drain(..) {
7680 results[index] = Some(Err(error.clone()));
7681 }
7682 break;
7683 }
7684 };
7685 let entry = match self.consensus.propose_at_cancellable(
7686 slot,
7687 last_hash,
7688 Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7689 &self.operation_cancelled,
7690 ) {
7691 Ok(entry) => entry,
7692 Err(error) => {
7693 let error = self.map_consensus_error(error);
7694 for index in pending.drain(..) {
7695 results[index] = Some(Err(error.clone()));
7696 }
7697 break;
7698 }
7699 };
7700 if let Err(error) = self.persist_entry(&entry, slot, last_hash) {
7701 for index in pending.drain(..) {
7702 results[index] = Some(Err(error.clone()));
7703 }
7704 break;
7705 }
7706
7707 let mut remaining = Vec::new();
7708 for index in pending.drain(..) {
7709 let member = &members[index];
7710 let QueuedOperation::Graph(command) = &member.operation else {
7711 unreachable!("graph pending members were validated above");
7712 };
7713 match self.check_graph_request(command.request_id(), &member.payload) {
7714 Ok(Some(record)) => {
7715 results[index] = Some(Ok(ClientWriteResponse::Graph(
7716 GraphMutationOutcome::from_record(record),
7717 )));
7718 }
7719 Ok(None) => remaining.push(index),
7720 Err(error) => results[index] = Some(Err(error)),
7721 }
7722 }
7723 if entry.entry_type == EntryType::Command
7724 && entry.payload == proposal_payload
7725 && remaining
7726 .iter()
7727 .any(|index| proposed_indices.contains(index))
7728 {
7729 let error = self.latch(NodeError::Invariant(
7730 "committed graph batch did not record every request".into(),
7731 ));
7732 for index in remaining.drain(..) {
7733 results[index] = Some(Err(error.clone()));
7734 }
7735 }
7736 pending = remaining;
7737 }
7738
7739 for (index, canonical) in aliases.into_iter().enumerate() {
7740 if let Some(canonical) = canonical {
7741 results[index] = results[canonical].clone();
7742 }
7743 }
7744
7745 results
7746 .into_iter()
7747 .map(|result| {
7748 result.unwrap_or_else(|| {
7749 Err(self.latch(NodeError::Invariant(
7750 "graph writer batch omitted a request result".into(),
7751 )))
7752 })
7753 })
7754 .collect()
7755 }
7756
7757 #[cfg(feature = "kv")]
7758 #[cfg_attr(
7759 all(not(feature = "sql"), not(feature = "graph")),
7760 allow(irrefutable_let_patterns, unreachable_patterns)
7761 )]
7762 fn execute_kv_client_batch(
7763 &self,
7764 members: Vec<RuntimeBatchMember>,
7765 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7766 let _commit = match self.lock_commit() {
7767 Ok(commit) => commit,
7768 Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7769 };
7770 if let Err(error) = self
7771 .ensure_ready()
7772 .and_then(|_| self.ensure_writes_active())
7773 {
7774 return members.into_iter().map(|_| Err(error.clone())).collect();
7775 }
7776 self.execute_kv_client_batch_locked(&members)
7777 }
7778
7779 #[cfg(feature = "kv")]
7780 #[cfg_attr(
7781 all(not(feature = "sql"), not(feature = "graph")),
7782 allow(irrefutable_let_patterns, unreachable_patterns)
7783 )]
7784 fn execute_kv_client_batch_locked(
7785 &self,
7786 members: &[RuntimeBatchMember],
7787 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7788 let mut results = vec![None; members.len()];
7789 let mut pending = Vec::new();
7790 let mut canonical_by_request = HashMap::new();
7791 let mut aliases = vec![None; members.len()];
7792 let mut lookup_indices = Vec::with_capacity(members.len());
7793 for (index, member) in members.iter().enumerate() {
7794 let QueuedOperation::Kv(command) = &member.operation else {
7795 results[index] = Some(Err(NodeError::ExecutionProfileMismatch {
7796 expected: ExecutionProfile::Kv,
7797 actual: ExecutionProfile::Sqlite,
7798 }));
7799 continue;
7800 };
7801 match classify_pending_request(
7802 &mut canonical_by_request,
7803 members,
7804 index,
7805 command.request_id(),
7806 ) {
7807 Ok(None) => lookup_indices.push(index),
7808 Ok(Some(canonical)) => aliases[index] = Some(canonical),
7809 Err(error) => results[index] = Some(Err(error)),
7810 }
7811 }
7812 let preflight = match self.check_kv_members_bulk(members, &lookup_indices) {
7813 Ok(preflight) => preflight,
7814 Err(error) => return members.iter().map(|_| Err(error.clone())).collect(),
7815 };
7816 for (index, lookup) in preflight {
7817 match lookup {
7818 Ok(Some(record)) => {
7819 results[index] = Some(Ok(ClientWriteResponse::Kv(
7820 KvMutationOutcome::from_record(record),
7821 )));
7822 }
7823 Ok(None) => pending.push(index),
7824 Err(error) => results[index] = Some(Err(error)),
7825 }
7826 }
7827
7828 while !pending.is_empty() {
7829 if pending.len() == 1 {
7830 let index = pending[0];
7831 results[index] = Some(self.execute_profile_member_locked(&members[index]));
7832 break;
7833 }
7834 let commands = pending
7835 .iter()
7836 .map(|index| match &members[*index].operation {
7837 QueuedOperation::Kv(command) => command.clone(),
7838 _ => unreachable!("KV pending members were validated above"),
7839 })
7840 .collect::<Vec<_>>();
7841 let full_payload = match encode_replicated_kv_batch(&commands) {
7842 Ok(payload) => payload,
7843 Err(error) => {
7844 let error = NodeError::InvalidRequest(error.to_string());
7845 for index in pending.drain(..) {
7846 results[index] = Some(Err(error.clone()));
7847 }
7848 break;
7849 }
7850 };
7851 let (proposal_count, proposal_payload) = if full_payload.len() <= MAX_COMMAND_BYTES {
7852 (commands.len(), full_payload)
7853 } else {
7854 let Some(prefix) = largest_fitting_kv_batch_prefix(&commands) else {
7855 let index = pending.remove(0);
7856 results[index] = Some(self.execute_profile_member_locked(&members[index]));
7857 continue;
7858 };
7859 prefix
7860 };
7861 let proposed_indices = pending[..proposal_count].to_vec();
7862 let (last_index, last_hash) = match self.ensure_materialized_tip() {
7863 Ok(tip) => tip,
7864 Err(error) => {
7865 for index in pending.drain(..) {
7866 results[index] = Some(Err(error.clone()));
7867 }
7868 break;
7869 }
7870 };
7871 let slot = match last_index.checked_add(1) {
7872 Some(slot) => slot,
7873 None => {
7874 let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7875 for index in pending.drain(..) {
7876 results[index] = Some(Err(error.clone()));
7877 }
7878 break;
7879 }
7880 };
7881 let entry = match self.consensus.propose_at_cancellable(
7882 slot,
7883 last_hash,
7884 Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7885 &self.operation_cancelled,
7886 ) {
7887 Ok(entry) => entry,
7888 Err(error) => {
7889 let error = self.map_consensus_error(error);
7890 for index in pending.drain(..) {
7891 results[index] = Some(Err(error.clone()));
7892 }
7893 break;
7894 }
7895 };
7896 if let Err(error) = self.persist_entry(&entry, slot, last_hash) {
7897 for index in pending.drain(..) {
7898 results[index] = Some(Err(error.clone()));
7899 }
7900 break;
7901 }
7902
7903 let current_pending = std::mem::take(&mut pending);
7904 let post_commit = match self.check_kv_members_bulk(members, ¤t_pending) {
7905 Ok(post_commit) => post_commit,
7906 Err(error) => {
7907 for index in current_pending {
7908 results[index] = Some(Err(error.clone()));
7909 }
7910 break;
7911 }
7912 };
7913 let mut remaining = Vec::new();
7914 for (index, lookup) in post_commit {
7915 match lookup {
7916 Ok(Some(record)) => {
7917 results[index] = Some(Ok(ClientWriteResponse::Kv(
7918 KvMutationOutcome::from_record(record),
7919 )));
7920 }
7921 Ok(None) => remaining.push(index),
7922 Err(error) => results[index] = Some(Err(error)),
7923 }
7924 }
7925 if entry.entry_type == EntryType::Command
7926 && entry.payload == proposal_payload
7927 && remaining
7928 .iter()
7929 .any(|index| proposed_indices.contains(index))
7930 {
7931 let error = self.latch(NodeError::Invariant(
7932 "committed KV batch did not record every request".into(),
7933 ));
7934 for index in remaining.drain(..) {
7935 results[index] = Some(Err(error.clone()));
7936 }
7937 }
7938 pending = remaining;
7939 }
7940
7941 for (index, canonical) in aliases.into_iter().enumerate() {
7942 if let Some(canonical) = canonical {
7943 results[index] = results[canonical].clone();
7944 }
7945 }
7946
7947 results
7948 .into_iter()
7949 .map(|result| {
7950 result.unwrap_or_else(|| {
7951 Err(self.latch(NodeError::Invariant(
7952 "KV writer batch omitted a request result".into(),
7953 )))
7954 })
7955 })
7956 .collect()
7957 }
7958
7959 #[cfg(feature = "kv")]
7960 #[allow(clippy::infallible_destructuring_match)]
7961 fn check_kv_members_bulk(
7962 &self,
7963 members: &[RuntimeBatchMember],
7964 indices: &[usize],
7965 ) -> Result<Vec<KvMemberCheck>, NodeError> {
7966 if indices.is_empty() {
7967 return Ok(Vec::new());
7968 }
7969 let materializer = self.lock_materializer()?;
7970 let kv = match &*materializer {
7971 Materializer::Kv(kv) => kv,
7972 #[cfg(feature = "sql")]
7973 Materializer::Sql(_) => {
7974 return Err(NodeError::ExecutionProfileMismatch {
7975 expected: ExecutionProfile::Kv,
7976 actual: ExecutionProfile::Sqlite,
7977 });
7978 }
7979 #[cfg(feature = "graph")]
7980 Materializer::Graph(_) => {
7981 return Err(NodeError::ExecutionProfileMismatch {
7982 expected: ExecutionProfile::Kv,
7983 actual: ExecutionProfile::Graph,
7984 });
7985 }
7986 };
7987 let requests = indices
7988 .iter()
7989 .map(|index| {
7990 let command = match &members[*index].operation {
7991 QueuedOperation::Kv(command) => command,
7992 #[cfg(feature = "sql")]
7993 QueuedOperation::KeyValue { .. } | QueuedOperation::Sql(_) => {
7994 unreachable!("KV receipt lookup contains only KV members")
7995 }
7996 #[cfg(feature = "graph")]
7997 QueuedOperation::Graph(_) => {
7998 unreachable!("KV receipt lookup contains only KV members")
7999 }
8000 };
8001 (command.request_id(), members[*index].payload.as_slice())
8002 })
8003 .collect::<Vec<_>>();
8004 let lookups = kv
8005 .check_requests(&requests)
8006 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
8007 if lookups.len() != indices.len() {
8008 return Err(self.latch(NodeError::Invariant(
8009 "KV bulk receipt lookup returned a misaligned result vector".into(),
8010 )));
8011 }
8012 Ok(indices
8013 .iter()
8014 .copied()
8015 .zip(
8016 lookups.into_iter().map(|lookup| {
8017 lookup.map_err(|error| NodeError::InvalidRequest(error.to_string()))
8018 }),
8019 )
8020 .collect())
8021 }
8022
8023 fn execute_profile_member_locked(
8024 &self,
8025 member: &RuntimeBatchMember,
8026 ) -> Result<ClientWriteResponse, NodeError> {
8027 match &member.operation {
8028 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
8029 QueuedOperation::Unavailable => unreachable!("no execution profiles are compiled in"),
8030 #[cfg(feature = "graph")]
8031 QueuedOperation::Graph(command) => self
8032 .mutate_graph_payload_locked(command, member.payload.clone())
8033 .map(ClientWriteResponse::Graph),
8034 #[cfg(feature = "kv")]
8035 QueuedOperation::Kv(command) => self
8036 .mutate_kv_payload_locked(command, member.payload.clone())
8037 .map(ClientWriteResponse::Kv),
8038 #[cfg(feature = "sql")]
8039 QueuedOperation::KeyValue { .. } | QueuedOperation::Sql(_) => {
8040 Err(NodeError::ExecutionProfileMismatch {
8041 expected: self.config.execution_profile,
8042 actual: ExecutionProfile::Sqlite,
8043 })
8044 }
8045 }
8046 }
8047
8048 #[cfg(feature = "sql")]
8049 fn execute_single_member_locked(
8050 &self,
8051 member: &RuntimeBatchMember,
8052 ) -> Result<ClientWriteResponse, NodeError> {
8053 if let QueuedOperation::Sql(command) = &member.operation {
8054 self.execute_sql_payload_locked(command, member.payload.clone())
8055 .map(|outcome| {
8056 ClientWriteResponse::Sql(sql_execute_response(
8057 outcome.response,
8058 outcome.sql_result,
8059 ))
8060 })
8061 } else if let QueuedOperation::KeyValue { key, value } = &member.operation {
8062 self.execute_put_payload_locked(&member.request_id, key, value, member.payload.clone())
8063 .map(|outcome| ClientWriteResponse::KeyValue(outcome.response))
8064 } else {
8065 Err(NodeError::ExecutionProfileMismatch {
8066 expected: ExecutionProfile::Sqlite,
8067 actual: self.config.execution_profile,
8068 })
8069 }
8070 }
8071
8072 #[cfg(feature = "sql")]
8073 fn execute_sql_payload_locked(
8074 &self,
8075 command: &SqlCommand,
8076 request_payload: Vec<u8>,
8077 ) -> Result<ExecutedPayload, NodeError> {
8078 self.ensure_ready()?;
8079 self.ensure_writes_active()?;
8080 if let Some(outcome) = self.check_request(&command.request_id, &request_payload)? {
8081 return Ok(ExecutedPayload {
8082 response: write_response(outcome),
8083 sql_result: self.replay_sql_result(
8084 &command.request_id,
8085 &request_payload,
8086 outcome,
8087 )?,
8088 });
8089 }
8090
8091 loop {
8092 let (last_index, last_hash) = self.ensure_materialized_tip()?;
8093 let proposal_payload =
8094 self.prepare_sql_proposal(command, &request_payload, last_index, last_hash)?;
8095 let slot = last_index.checked_add(1).ok_or_else(|| {
8096 self.latch(NodeError::Invariant("qlog index is exhausted".into()))
8097 })?;
8098 let entry = self
8099 .consensus
8100 .propose_at_cancellable(
8101 slot,
8102 last_hash,
8103 Command::new(CommandKind::Deterministic, proposal_payload.clone()),
8104 &self.operation_cancelled,
8105 )
8106 .map_err(|error| self.map_consensus_error(error))?;
8107 let sql_result = self.persist_entry(&entry, slot, last_hash)?;
8108 if let Some(outcome) = self.check_request(&command.request_id, &request_payload)? {
8109 return Ok(ExecutedPayload {
8110 response: write_response(outcome),
8111 sql_result: sql_result.or(self.replay_sql_result(
8112 &command.request_id,
8113 &request_payload,
8114 outcome,
8115 )?),
8116 });
8117 }
8118 if entry.entry_type == EntryType::Command && entry.payload == proposal_payload {
8119 return Err(self.latch(NodeError::Invariant(
8120 "committed SQL request was not recorded by SQLite".into(),
8121 )));
8122 }
8123 }
8124 }
8125
8126 #[cfg(feature = "sql")]
8127 fn prepare_sql_proposal(
8128 &self,
8129 command: &SqlCommand,
8130 request_payload: &[u8],
8131 base_index: LogIndex,
8132 base_hash: LogHash,
8133 ) -> Result<Vec<u8>, NodeError> {
8134 let sqlite = self.lock_sqlite()?;
8135 let preparation = sqlite.prepare_sql_batch_effect(
8136 &[SqlBatchMember {
8137 command,
8138 request_payload,
8139 }],
8140 base_index,
8141 base_hash,
8142 );
8143 let preparation = match preparation {
8144 Ok(preparation) => preparation,
8145 Err(rhiza_sql::Error::ResourceExhausted(message)) => {
8146 return Err(NodeError::ResourceExhausted(message));
8147 }
8148 Err(error) => return Err(self.map_sqlite_error(error)),
8149 };
8150 let result = preparation
8151 .results
8152 .into_iter()
8153 .next()
8154 .expect("one-member SQL preparation returns one result");
8155 if let Err(error) = result {
8156 if let rhiza_sql::Error::ResourceExhausted(message) = error {
8157 return Err(NodeError::ResourceExhausted(message));
8158 }
8159 if let rhiza_sql::Error::RequestConflict(conflict) = error {
8160 return Err(NodeError::RequestConflict(conflict));
8161 }
8162 let message = error.to_string();
8163 let statement_index = first_invalid_sql_statement(command, |prefix| {
8164 let Ok(prefix_payload) = encode_sql_command(prefix) else {
8165 return true;
8166 };
8167 let prefix_member = [SqlBatchMember {
8168 command: prefix,
8169 request_payload: &prefix_payload,
8170 }];
8171 match sqlite.prepare_sql_batch_effect(&prefix_member, base_index, base_hash) {
8172 Ok(preparation) => preparation
8173 .results
8174 .into_iter()
8175 .next()
8176 .is_none_or(|result| result.is_err()),
8177 Err(_) => true,
8178 }
8179 });
8180 return match statement_index {
8181 Some(statement_index) => Err(NodeError::InvalidSqlStatement {
8182 statement_index,
8183 message,
8184 }),
8185 None => Err(NodeError::InvalidRequest(message)),
8186 };
8187 }
8188 let payload = preparation.effect.ok_or_else(|| {
8189 self.latch(NodeError::Invariant(
8190 "successful SQL preparation omitted its QWAL v3 effect".into(),
8191 ))
8192 })?;
8193 if !payload.starts_with(QWAL_V3_MAGIC) {
8194 return Err(self.latch(NodeError::Invariant(
8195 "SQLite materializer prepared a non-QWAL v3 SQL proposal".into(),
8196 )));
8197 }
8198 if payload.len() > MAX_COMMAND_BYTES {
8199 return Err(NodeError::ResourceExhausted(format!(
8200 "SQL effect exceeds {MAX_COMMAND_BYTES} bytes"
8201 )));
8202 }
8203 Ok(payload)
8204 }
8205
8206 #[cfg(feature = "sql")]
8207 fn execute_put_payload_locked(
8208 &self,
8209 request_id: &str,
8210 key: &str,
8211 value: &str,
8212 payload: Vec<u8>,
8213 ) -> Result<ExecutedPayload, NodeError> {
8214 self.ensure_ready()?;
8215 self.ensure_writes_active()?;
8216
8217 if let Some(outcome) = self.check_request(request_id, &payload)? {
8218 return Ok(ExecutedPayload {
8219 response: write_response(outcome),
8220 sql_result: None,
8221 });
8222 }
8223
8224 loop {
8225 let (last_index, last_hash) = self.ensure_materialized_tip()?;
8226 let proposal_payload = self
8227 .lock_sqlite()?
8228 .prepare_put_effect(request_id, key, value, &payload, last_index, last_hash)
8229 .map_err(|error| self.map_sqlite_error(error))?;
8230 if !proposal_payload.starts_with(QWAL_V3_MAGIC) {
8231 return Err(self.latch(NodeError::Invariant(
8232 "SQLite materializer prepared a non-QWAL v3 key/value write proposal".into(),
8233 )));
8234 }
8235 if proposal_payload.len() > MAX_COMMAND_BYTES {
8236 return Err(NodeError::ResourceExhausted(format!(
8237 "SQLite QWAL effect exceeds {MAX_COMMAND_BYTES} bytes"
8238 )));
8239 }
8240 let slot = last_index.checked_add(1).ok_or_else(|| {
8241 self.latch(NodeError::Invariant("qlog index is exhausted".into()))
8242 })?;
8243 let entry = self
8244 .consensus
8245 .propose_at_cancellable(
8246 slot,
8247 last_hash,
8248 Command::new(CommandKind::Deterministic, proposal_payload.clone()),
8249 &self.operation_cancelled,
8250 )
8251 .map_err(|error| self.map_consensus_error(error))?;
8252 self.persist_entry(&entry, slot, last_hash)?;
8253
8254 if let Some(outcome) = self.check_request(request_id, &payload)? {
8255 return Ok(ExecutedPayload {
8256 response: write_response(outcome),
8257 sql_result: None,
8258 });
8259 }
8260 if entry.entry_type == EntryType::Command && entry.payload == proposal_payload {
8261 return Err(self.latch(NodeError::Invariant(
8262 "committed key/value write request was not recorded by SQLite QWAL".into(),
8263 )));
8264 }
8265 }
8266 }
8267
8268 #[cfg(feature = "sql")]
8269 fn replay_sql_result(
8270 &self,
8271 request_id: &str,
8272 payload: &[u8],
8273 outcome: RequestOutcome,
8274 ) -> Result<Option<SqlCommandResult>, NodeError> {
8275 let sqlite = self.lock_sqlite()?;
8276 let stored = sqlite
8277 .check_sql_request(request_id, payload)
8278 .map_err(|error| self.map_sqlite_error(error))?
8279 .ok_or_else(|| {
8280 self.latch(NodeError::Invariant(
8281 "committed SQL request result is missing".into(),
8282 ))
8283 })?;
8284 if stored.0 != outcome {
8285 return Err(self.latch(NodeError::Invariant(
8286 "stored SQL request outcome changed".into(),
8287 )));
8288 }
8289 Ok(stored.1)
8290 }
8291
8292 #[cfg(feature = "sql")]
8293 fn map_sql_batch_member_error(&self, error: rhiza_sql::Error) -> NodeError {
8294 match error {
8295 rhiza_sql::Error::RequestConflict(conflict) => NodeError::RequestConflict(conflict),
8296 rhiza_sql::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
8297 rhiza_sql::Error::InvalidCommand(message) | rhiza_sql::Error::Sqlite(message) => {
8298 NodeError::InvalidSqlStatement {
8299 statement_index: 0,
8300 message,
8301 }
8302 }
8303 other => self.map_sqlite_error(other),
8304 }
8305 }
8306
8307 #[cfg(feature = "sql")]
8308 pub fn read(&self, key: &str, consistency: ReadConsistency) -> Result<ReadResponse, NodeError> {
8309 validate_key(key)?;
8310 match consistency {
8311 ReadConsistency::Local => self.read_local(key, None),
8312 ReadConsistency::AppliedIndex(required) => self.read_local(key, Some(required)),
8313 ReadConsistency::ReadBarrier => {
8314 let anchor = self.establish_read_barrier()?;
8315 let _commit = self.lock_commit()?;
8316 self.ensure_ready()?;
8317 self.ensure_writes_active()?;
8318 self.validate_read_barrier_descendant_locked(anchor)?;
8319 self.read_local(key, Some(anchor.index()))
8320 }
8321 }
8322 }
8323
8324 #[cfg(feature = "sql")]
8325 pub fn query_sql(
8326 &self,
8327 statement: &SqlStatement,
8328 consistency: ReadConsistency,
8329 max_rows: u32,
8330 ) -> Result<SqlQueryResponse, NodeError> {
8331 if max_rows == 0 || max_rows > MAX_SQL_MAX_ROWS {
8332 return Err(NodeError::InvalidRequest(format!(
8333 "max_rows must be between 1 and {MAX_SQL_MAX_ROWS}"
8334 )));
8335 }
8336 match consistency {
8337 ReadConsistency::Local => self.query_sql_local(statement, None, max_rows),
8338 ReadConsistency::AppliedIndex(required) => {
8339 self.query_sql_local(statement, Some(required), max_rows)
8340 }
8341 ReadConsistency::ReadBarrier => {
8342 let anchor = self.establish_read_barrier()?;
8343 let _commit = self.lock_commit()?;
8344 self.ensure_ready()?;
8345 self.ensure_writes_active()?;
8346 self.validate_read_barrier_descendant_locked(anchor)?;
8347 self.query_sql_local(statement, Some(anchor.index()), max_rows)
8348 }
8349 }
8350 }
8351
8352 pub fn applied_index(&self) -> Result<LogIndex, NodeError> {
8353 self.ensure_ready()?;
8354 self.lock_materializer()?
8355 .applied_index()
8356 .map_err(|error| self.latch(NodeError::Storage(error)))
8357 }
8358
8359 pub fn applied_hash(&self) -> Result<LogHash, NodeError> {
8360 self.ensure_ready()?;
8361 self.lock_materializer()?
8362 .applied_hash()
8363 .map_err(|error| self.latch(NodeError::Storage(error)))
8364 }
8365
8366 pub fn cancel_operations(&self) {
8367 self.operation_cancelled.store(true, Ordering::Release);
8368 #[cfg(feature = "sql")]
8369 self.sql_group_commit.fail_pending(NodeError::Unavailable(
8370 "SQL group commit cancelled during shutdown".into(),
8371 ));
8372 #[cfg(feature = "kv")]
8373 self.kv_group_commit.fail_pending(NodeError::Unavailable(
8374 "KV group commit cancelled during shutdown".into(),
8375 ));
8376 self.read_barriers.cancel_waiters();
8377 self.operation_cancelled_notify.notify_waiters();
8378 }
8379
8380 pub fn materialize_next_decision(&self) -> Result<bool, NodeError> {
8381 let _commit = self.lock_commit()?;
8382 self.ensure_ready()?;
8383 let (last_index, last_hash) = self.ensure_materialized_tip()?;
8384 let slot = last_index
8385 .checked_add(1)
8386 .ok_or_else(|| self.latch(NodeError::Invariant("qlog index is exhausted".into())))?;
8387 match self
8388 .consensus
8389 .inspect_decision_at(slot, last_hash)
8390 .map_err(|error| self.map_consensus_error(error))?
8391 {
8392 DecisionInspection::Committed(entry) => {
8393 self.persist_entry(&entry, slot, last_hash)?;
8394 Ok(true)
8395 }
8396 DecisionInspection::Empty | DecisionInspection::Pending => Ok(false),
8397 DecisionInspection::Unavailable => Err(NodeError::Unavailable(
8398 "decision proof inspection did not reach quorum".into(),
8399 )),
8400 }
8401 }
8402
8403 pub async fn run_background_materializer<F>(
8404 self: Arc<Self>,
8405 poll_interval: Duration,
8406 shutdown: F,
8407 ) -> Result<(), NodeError>
8408 where
8409 F: std::future::Future<Output = ()> + Send,
8410 {
8411 let poll_interval = poll_interval.max(Duration::from_millis(10));
8412 tokio::pin!(shutdown);
8413 loop {
8414 tokio::select! {
8415 () = &mut shutdown => return Ok(()),
8416 () = tokio::time::sleep(poll_interval) => {
8417 loop {
8418 let runtime = Arc::clone(&self);
8419 let mut operation = tokio::task::spawn_blocking(move || runtime.materialize_next_decision());
8420 let (result, shutting_down) = tokio::select! {
8421 () = &mut shutdown => {
8422 self.cancel_operations();
8423 (operation.await, true)
8424 }
8425 result = &mut operation => (result, false),
8426 };
8427 if shutting_down {
8428 return match result {
8429 Ok(Ok(_) | Err(NodeError::Unavailable(_) | NodeError::Contention(_))) => Ok(()),
8430 Ok(Err(error)) => Err(error),
8431 Err(error) => Err(NodeError::Fatal(format!("materializer task failed: {error}"))),
8432 };
8433 }
8434 match result {
8435 Ok(Ok(true)) => continue,
8436 Ok(Ok(false) | Err(NodeError::Unavailable(_) | NodeError::Contention(_))) => break,
8437 Ok(Err(error)) => return Err(error),
8438 Err(error) => return Err(NodeError::Fatal(format!("materializer task failed: {error}"))),
8439 }
8440 }
8441 }
8442 }
8443 }
8444 }
8445
8446 pub const fn config(&self) -> &NodeConfig {
8447 &self.config
8448 }
8449
8450 pub const fn consensus(&self) -> &Arc<ThreeNodeConsensus> {
8451 &self.consensus
8452 }
8453
8454 pub const fn log_store(&self) -> &FileLogStore {
8455 &self.log_store
8456 }
8457
8458 pub fn configuration_state(&self) -> Result<ConfigurationState, NodeError> {
8459 self.log_store
8460 .configuration_state()
8461 .map_err(|error| NodeError::Storage(error.to_string()))
8462 }
8463
8464 pub fn status(&self) -> Result<NodeStatus, NodeError> {
8465 let configuration_state = self.configuration_state()?;
8466 let (configuration_status, active_config_id) = if configuration_state.is_active() {
8467 (
8468 RuntimeConfigurationStatus::Active,
8469 configuration_state.config_id(),
8470 )
8471 } else if configuration_state.config_id() == self.consensus.config_id() {
8472 (
8473 RuntimeConfigurationStatus::Stopped,
8474 configuration_state.config_id(),
8475 )
8476 } else {
8477 (
8478 RuntimeConfigurationStatus::AwaitingActivation,
8479 configuration_state
8480 .config_id()
8481 .checked_add(1)
8482 .ok_or_else(|| {
8483 NodeError::Invariant("successor configuration id is exhausted".into())
8484 })?,
8485 )
8486 };
8487 Ok(NodeStatus {
8488 ready: self.is_ready(),
8489 stop_anchor: configuration_state.stop().copied(),
8490 active_config_id,
8491 active_membership_digest: self.config.membership.digest(),
8492 configuration_status,
8493 configuration_state,
8494 })
8495 }
8496
8497 pub fn stop_current_configuration_for_successor(
8498 &self,
8499 successor: &Membership,
8500 ) -> Result<StopInformation, NodeError> {
8501 let _commit = self.lock_commit()?;
8502 self.stop_current_configuration_locked(successor)
8503 }
8504
8505 fn stop_current_configuration_locked(
8506 &self,
8507 successor: &Membership,
8508 ) -> Result<StopInformation, NodeError> {
8509 self.ensure_ready()?;
8510 self.ensure_writes_active()?;
8511 let state = self.configuration_state()?;
8512 let stop_command = ConfigChange::bound_stop(
8513 self.config.cluster_id.clone(),
8514 state.config_id(),
8515 state.digest(),
8516 state
8517 .config_id()
8518 .checked_add(1)
8519 .ok_or_else(|| NodeError::Invariant("successor config id is exhausted".into()))?,
8520 successor.members().to_vec(),
8521 )
8522 .map_err(|error| NodeError::Invariant(error.to_string()))?
8523 .to_stored_command();
8524 loop {
8525 let (last_index, last_hash) = self.ensure_materialized_tip()?;
8526 let slot = last_index
8527 .checked_add(1)
8528 .ok_or_else(|| NodeError::Invariant("qlog index is exhausted".into()))?;
8529 let entry = self
8530 .consensus
8531 .propose_stored_at(slot, last_hash, stop_command.clone())
8532 .map_err(|error| self.map_consensus_error(error))?;
8533 self.persist_entry(&entry, slot, last_hash)?;
8534 let decided = StoredCommand::new(entry.entry_type, entry.payload.clone());
8535 if decided != stop_command {
8536 let current = self.configuration_state()?;
8537 if current.is_active() {
8538 continue;
8539 }
8540 return Err(NodeError::ConfigurationTransition {
8541 state: Box::new(current),
8542 });
8543 }
8544 let proof = self
8545 .consensus
8546 .inspect_decision_proof_at(slot)
8547 .map_err(|error| self.map_consensus_error(error))?
8548 .ok_or_else(|| {
8549 NodeError::Unavailable("durable Stop proof is unavailable".into())
8550 })?;
8551 if proof
8552 .proposal()
8553 .value
8554 .as_ref()
8555 .map(|value| value.entry_hash)
8556 != Some(entry.hash)
8557 {
8558 return Err(self.latch(NodeError::Reconciliation(
8559 "Stop proof differs from committed stop entry".into(),
8560 )));
8561 }
8562 return Ok(StopInformation { entry, proof });
8563 }
8564 }
8565
8566 pub fn activate_successor(&self) -> Result<LogEntry, NodeError> {
8567 let _commit = self.lock_commit()?;
8568 self.activate_successor_locked(None)
8569 }
8570
8571 pub fn activate_successor_if(&self, expected_config_id: u64) -> Result<LogEntry, NodeError> {
8572 let _commit = self.lock_commit()?;
8573 self.activate_successor_locked(Some(expected_config_id))
8574 }
8575
8576 fn activate_successor_locked(
8577 &self,
8578 expected_config_id: Option<u64>,
8579 ) -> Result<LogEntry, NodeError> {
8580 self.ensure_ready()?;
8581 let state = self.configuration_state()?;
8582 let stop = state
8583 .stop()
8584 .copied()
8585 .ok_or_else(|| NodeError::ConfigurationTransition {
8586 state: Box::new(state.clone()),
8587 })?;
8588 if state.config_id() == self.consensus.config_id() {
8589 return Err(NodeError::ConfigurationTransition {
8590 state: Box::new(state),
8591 });
8592 }
8593 let successor_config_id = state.config_id().checked_add(1).ok_or_else(|| {
8594 NodeError::Invariant("successor configuration id is exhausted".into())
8595 })?;
8596 if expected_config_id.is_some_and(|expected| expected != successor_config_id) {
8597 return Err(NodeError::PreconditionFailed(
8598 "successor configuration does not match expected_config_id".into(),
8599 ));
8600 }
8601 let stop_entry = self.recover_stop_entry(stop)?;
8602 let entry = self
8603 .consensus
8604 .propose_activation_for_stop_entry(&stop_entry)
8605 .map_err(|error| self.map_consensus_error(error))?;
8606 self.persist_entry(&entry, stop.index() + 1, stop.hash())?;
8607 Ok(entry)
8608 }
8609
8610 pub(crate) fn recover_stop_entry(&self, stop: LogAnchor) -> Result<LogEntry, NodeError> {
8611 if let Some(entry) = self
8612 .log_store
8613 .read(stop.index())
8614 .map_err(|error| NodeError::Storage(error.to_string()))?
8615 .filter(|entry| entry.hash == stop.hash())
8616 {
8617 return Ok(entry);
8618 }
8619 if let Some(entry) = self
8620 .config
8621 .predecessor_stop_entry
8622 .as_ref()
8623 .filter(|entry| entry.index == stop.index() && entry.hash == stop.hash())
8624 {
8625 validate_entry_envelope(&self.config, entry, entry.index, entry.prev_hash)?;
8626 return Ok(entry.clone());
8627 }
8628 let proof = self
8629 .consensus
8630 .inspect_decision_proof_at(stop.index())
8631 .map_err(|error| self.map_consensus_error(error))?
8632 .ok_or_else(|| NodeError::Unavailable("durable Stop proof is unavailable".into()))?;
8633 let value = proof
8634 .proposal()
8635 .value
8636 .as_ref()
8637 .filter(|value| value.entry_hash == stop.hash())
8638 .ok_or_else(|| {
8639 self.latch(NodeError::Reconciliation(
8640 "Stop proof differs from compacted anchor".into(),
8641 ))
8642 })?;
8643 match self
8644 .consensus
8645 .inspect_decision_at(stop.index(), value.prev_hash)
8646 .map_err(|error| self.map_consensus_error(error))?
8647 {
8648 DecisionInspection::Committed(entry) if entry.hash == stop.hash() => Ok(entry),
8649 DecisionInspection::Unavailable => Err(NodeError::Unavailable(
8650 "durable Stop command is unavailable".into(),
8651 )),
8652 DecisionInspection::Committed(_)
8653 | DecisionInspection::Empty
8654 | DecisionInspection::Pending => Err(self.latch(NodeError::Reconciliation(
8655 "Stop decision differs from compacted anchor".into(),
8656 ))),
8657 }
8658 }
8659
8660 pub fn log_root(&self) -> Result<LogAnchor, NodeError> {
8661 let _commit = self.lock_commit()?;
8662 self.log_root_unlocked()
8663 }
8664
8665 fn log_root_unlocked(&self) -> Result<LogAnchor, NodeError> {
8666 let state = self
8667 .log_store
8668 .logical_state()
8669 .map_err(|error| NodeError::Storage(error.to_string()))?;
8670 Ok(state.tip.map_or_else(
8671 || {
8672 state
8673 .anchor
8674 .map_or(LogAnchor::new(0, LogHash::ZERO), |anchor| {
8675 *anchor.compacted()
8676 })
8677 },
8678 |entry| LogAnchor::new(entry.index(), entry.hash()),
8679 ))
8680 }
8681
8682 pub fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
8683 fetch_runtime_log(self, request)
8684 }
8685
8686 #[cfg(feature = "sql")]
8687 pub fn create_recovery_snapshot(&self) -> Result<RecoverySnapshot, NodeError> {
8688 let _commit = self.lock_commit()?;
8689 self.ensure_ready()?;
8690 self.ensure_materialized_tip()?;
8691 self.lock_sqlite()?
8692 .create_recovery_snapshot(self.config.recovery_generation)
8693 .map_err(|error| self.map_sqlite_error(error))
8694 }
8695
8696 pub async fn checkpoint_compact(
8697 &self,
8698 coordinator: &CheckpointCoordinator,
8699 ) -> Result<RecoveryAnchor, DurabilityError> {
8700 coordinator.checkpoint_compact(self).await
8701 }
8702
8703 #[cfg_attr(not(any(feature = "sql", feature = "kv")), allow(unused_variables))]
8704 pub(crate) fn compact_embedded_log_before(
8705 &self,
8706 anchor_index: LogIndex,
8707 ) -> Result<(), NodeError> {
8708 let materializer = self.lock_materializer()?;
8709 match &*materializer {
8710 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
8711 Materializer::Unavailable => unreachable!("no execution profiles are compiled in"),
8712 #[cfg(feature = "sql")]
8713 Materializer::Sql(sql) => sql
8714 .compact_embedded_log_before(anchor_index)
8715 .map_err(|error| self.map_sqlite_error(error)),
8716 #[cfg(feature = "kv")]
8717 Materializer::Kv(kv) => kv
8718 .compact_embedded_log_before(anchor_index)
8719 .map_err(|error| NodeError::Storage(error.to_string())),
8720 #[cfg(feature = "graph")]
8721 Materializer::Graph(_) => Ok(()),
8722 }
8723 }
8724
8725 #[cfg(feature = "sql")]
8726 pub fn verify_snapshot_publication(
8727 &self,
8728 snapshot: &RecoverySnapshot,
8729 publication: &SnapshotRecord,
8730 ) -> Result<VerifiedSnapshotPublication, NodeError> {
8731 let anchor = snapshot.anchor();
8732 let manifest = publication.manifest();
8733 let publication_digest = LogHash::from_hex(publication.sha256()).ok_or_else(|| {
8734 NodeError::Reconciliation("published snapshot digest is invalid".into())
8735 })?;
8736 if anchor.cluster_id() != self.config.cluster_id
8737 || anchor.epoch() != self.config.epoch
8738 || anchor.config_id() != self.config.config_id()
8739 || anchor.recovery_generation() != self.config.recovery_generation
8740 || manifest.cluster_id() != anchor.cluster_id()
8741 || manifest.epoch() != anchor.epoch()
8742 || manifest.config_id() != anchor.config_id()
8743 || manifest.configuration_state() != anchor.configuration_state()
8744 || manifest.index() != anchor.compacted().index()
8745 || manifest.applied_hash() != anchor.compacted().hash()
8746 || manifest.snapshot_id() != anchor.snapshot().snapshot_id()
8747 || manifest.executor_fingerprint() != anchor.snapshot().executor_fingerprint()
8748 || publication_digest != anchor.snapshot().digest()
8749 || publication.size_bytes() != anchor.snapshot().size_bytes()
8750 || LogHash::digest(&[snapshot.db_bytes()]) != anchor.snapshot().digest()
8751 || snapshot.db_bytes().len() as u64 != anchor.snapshot().size_bytes()
8752 {
8753 return Err(NodeError::Reconciliation(
8754 "published snapshot does not match the runtime recovery anchor".into(),
8755 ));
8756 }
8757 Ok(VerifiedSnapshotPublication {
8758 anchor: anchor.clone(),
8759 })
8760 }
8761
8762 #[cfg(feature = "sql")]
8763 pub fn compact_log(&self, publication: &VerifiedSnapshotPublication) -> Result<(), NodeError> {
8764 let _commit = self.lock_commit()?;
8765 self.ensure_ready()?;
8766 let applied_index = self.applied_index()?;
8767 let applied_hash = self.applied_hash()?;
8768 let anchor = &publication.anchor;
8769 if anchor.cluster_id() != self.config.cluster_id
8770 || anchor.epoch() != self.config.epoch
8771 || anchor.config_id() != self.config.config_id()
8772 || anchor.recovery_generation() != self.config.recovery_generation
8773 || anchor.compacted().index() != applied_index
8774 || anchor.compacted().hash() != applied_hash
8775 {
8776 return Err(NodeError::Reconciliation(
8777 "verified snapshot anchor does not match the current applied entry".into(),
8778 ));
8779 }
8780 self.log_store
8781 .compact_prefix(anchor)
8782 .map_err(|error| NodeError::Storage(error.to_string()))?;
8783 self.compact_embedded_log_before(anchor.compacted().index())
8784 }
8785
8786 pub fn is_ready(&self) -> bool {
8787 self.ready.load(Ordering::Acquire) && !self.fatal.load(Ordering::Acquire)
8788 }
8789
8790 pub fn is_fatal(&self) -> bool {
8791 self.fatal.load(Ordering::Acquire)
8792 }
8793
8794 pub fn fatal_reason(&self) -> Option<String> {
8795 self.fatal_reason
8796 .lock()
8797 .ok()
8798 .and_then(|reason| reason.clone())
8799 }
8800
8801 #[cfg(feature = "sql")]
8802 fn read_local(
8803 &self,
8804 key: &str,
8805 required_index: Option<LogIndex>,
8806 ) -> Result<ReadResponse, NodeError> {
8807 self.ensure_ready()?;
8808 self.ensure_writes_active()?;
8809 let sqlite = self.lock_sqlite()?;
8810 let (applied_index, hash) = sqlite
8811 .applied_tip_value()
8812 .map_err(|error| self.map_sqlite_error(error))?;
8813 if required_index.is_some_and(|required| applied_index < required) {
8814 return Err(NodeError::Unavailable(format!(
8815 "local applied index {applied_index} has not reached {}",
8816 required_index.expect("checked above")
8817 )));
8818 }
8819 let value = sqlite
8820 .get_value(key)
8821 .map_err(|error| self.map_sqlite_error(error))?;
8822 Ok(ReadResponse {
8823 value,
8824 applied_index,
8825 hash,
8826 })
8827 }
8828
8829 #[cfg(feature = "graph")]
8830 fn get_graph_document_local(
8831 &self,
8832 id: &str,
8833 required_index: Option<LogIndex>,
8834 ) -> Result<GraphReadResponse, NodeError> {
8835 self.ensure_ready()?;
8836 self.ensure_writes_active()?;
8837 let graph = self.graph_materializer()?;
8838 let (value, applied_index, hash) = graph
8839 .get_document_with_tip(id)
8840 .map_err(|error| self.map_graph_read_error(error))?;
8841 if required_index.is_some_and(|required| applied_index < required) {
8842 return Err(NodeError::Unavailable(format!(
8843 "local applied index {applied_index} has not reached {}",
8844 required_index.expect("checked above")
8845 )));
8846 }
8847 Ok(GraphReadResponse {
8848 value,
8849 applied_index,
8850 hash,
8851 })
8852 }
8853
8854 #[cfg(feature = "graph")]
8855 fn query_graph_local(
8856 &self,
8857 statement: &str,
8858 parameters: &BTreeMap<String, GraphParameterValue>,
8859 required_index: Option<LogIndex>,
8860 max_rows: u32,
8861 ) -> Result<GraphQueryResult, NodeError> {
8862 self.ensure_ready()?;
8863 self.ensure_writes_active()?;
8864 let graph = self.graph_materializer()?;
8865 let result = graph
8866 .query_read_only(
8867 statement,
8868 parameters,
8869 usize::try_from(max_rows).expect("u32 fits usize"),
8870 MAX_GRAPH_RESULT_BYTES,
8871 GRAPH_QUERY_TIMEOUT_MS,
8872 )
8873 .map_err(|error| self.map_graph_read_error(error))?;
8874 if required_index.is_some_and(|required| result.applied_index < required) {
8875 return Err(NodeError::Unavailable(format!(
8876 "local applied index {} has not reached {}",
8877 result.applied_index,
8878 required_index.expect("checked above")
8879 )));
8880 }
8881 Ok(result)
8882 }
8883
8884 #[cfg(feature = "kv")]
8885 fn get_kv_local(
8886 &self,
8887 key: &[u8],
8888 required_index: Option<LogIndex>,
8889 ) -> Result<KvReadResponse, NodeError> {
8890 self.ensure_ready()?;
8891 self.ensure_writes_active()?;
8892 let kv = self.kv_materializer()?;
8893 let result = kv
8894 .get_with_tip(key)
8895 .map_err(|error| self.map_kv_read_error(error))?;
8896 let (value, tip) = result.into_parts();
8897 let applied_index = tip.applied_index();
8898 if required_index.is_some_and(|required| applied_index < required) {
8899 return Err(NodeError::Unavailable(format!(
8900 "local applied index {applied_index} has not reached {}",
8901 required_index.expect("checked above")
8902 )));
8903 }
8904 Ok(KvReadResponse {
8905 value,
8906 applied_index,
8907 hash: tip.applied_hash(),
8908 })
8909 }
8910
8911 #[cfg(feature = "kv")]
8912 fn scan_kv_range_local(
8913 &self,
8914 start: &[u8],
8915 end: Option<&[u8]>,
8916 limit: usize,
8917 cursor: Option<&[u8]>,
8918 required_index: Option<LogIndex>,
8919 ) -> Result<KvScanResult, NodeError> {
8920 self.ensure_ready()?;
8921 self.ensure_writes_active()?;
8922 let kv = self.kv_materializer()?;
8923 let result = kv
8924 .scan_range(start, end, limit, cursor)
8925 .map_err(|error| self.map_kv_read_error(error))?;
8926 validate_kv_scan_required_index(&result, required_index)?;
8927 Ok(result)
8928 }
8929
8930 #[cfg(feature = "kv")]
8931 fn scan_kv_prefix_local(
8932 &self,
8933 prefix: &[u8],
8934 limit: usize,
8935 cursor: Option<&[u8]>,
8936 required_index: Option<LogIndex>,
8937 ) -> Result<KvScanResult, NodeError> {
8938 self.ensure_ready()?;
8939 self.ensure_writes_active()?;
8940 let kv = self.kv_materializer()?;
8941 let result = kv
8942 .scan_prefix(prefix, limit, cursor)
8943 .map_err(|error| self.map_kv_read_error(error))?;
8944 validate_kv_scan_required_index(&result, required_index)?;
8945 Ok(result)
8946 }
8947
8948 #[cfg(feature = "sql")]
8949 fn query_sql_local(
8950 &self,
8951 statement: &SqlStatement,
8952 required_index: Option<LogIndex>,
8953 max_rows: u32,
8954 ) -> Result<SqlQueryResponse, NodeError> {
8955 self.ensure_ready()?;
8956 self.ensure_writes_active()?;
8957 let sqlite = self.lock_sqlite()?;
8958 let (applied_index, hash) = sqlite
8959 .applied_tip_value()
8960 .map_err(|error| self.map_sqlite_error(error))?;
8961 if required_index.is_some_and(|required| applied_index < required) {
8962 return Err(NodeError::Unavailable(format!(
8963 "local applied index {applied_index} has not reached {}",
8964 required_index.expect("checked above")
8965 )));
8966 }
8967 let SqlQueryResult { columns, rows } = sqlite
8968 .query_sql(
8969 statement,
8970 usize::try_from(max_rows).expect("u32 fits usize"),
8971 MAX_SQL_RESULT_BYTES,
8972 )
8973 .map_err(|error| match error {
8974 rhiza_sql::Error::ResourceExhausted(message) => {
8975 NodeError::ResourceExhausted(message)
8976 }
8977 other => NodeError::InvalidSqlStatement {
8978 statement_index: 0,
8979 message: other.to_string(),
8980 },
8981 })?;
8982 Ok(SqlQueryResponse {
8983 columns,
8984 rows,
8985 applied_index,
8986 hash,
8987 })
8988 }
8989
8990 fn establish_read_barrier(&self) -> Result<LogAnchor, NodeError> {
8991 let participant = self.read_barriers.join().map_err(|error| match error {
8992 NodeError::Invariant(_) => self.latch(error),
8993 other => other,
8994 })?;
8995 let Some(mut publication) = participant.publication() else {
8996 return participant.wait(&self.operation_cancelled);
8997 };
8998
8999 let result = (|| {
9000 publication.wait_turn(&self.operation_cancelled)?;
9001 let _commit = self.lock_commit()?;
9005 publication.start(&self.operation_cancelled)?;
9006 self.ensure_ready()?;
9007 self.commit_read_barrier_locked()
9008 })();
9009 publication.publish(result.clone());
9010 result
9011 }
9012
9013 #[cfg(any(feature = "graph", feature = "kv"))]
9014 fn validate_read_barrier_before_snapshot(&self, anchor: LogAnchor) -> Result<(), NodeError> {
9015 {
9016 let _commit = self.lock_commit()?;
9017 self.ensure_ready()?;
9018 self.ensure_writes_active()?;
9019 self.validate_read_barrier_qlog_descendant_locked(anchor)?;
9020 }
9021 Ok(())
9022 }
9023
9024 #[cfg(any(feature = "graph", feature = "kv"))]
9025 fn validate_read_barrier_snapshot(
9026 &self,
9027 anchor: LogAnchor,
9028 observed: LogAnchor,
9029 ) -> Result<(), NodeError> {
9030 if observed.index() < anchor.index() {
9031 return Err(NodeError::Unavailable(format!(
9032 "read snapshot tip {} precedes read barrier {}",
9033 observed.index(),
9034 anchor.index()
9035 )));
9036 }
9037 if observed.index() == anchor.index() && observed.hash() != anchor.hash() {
9038 return Err(self.latch(NodeError::Invariant(
9039 "read snapshot tip hash differs from the read barrier anchor".into(),
9040 )));
9041 }
9042 Ok(())
9043 }
9044
9045 #[cfg(feature = "sql")]
9046 fn validate_read_barrier_descendant_locked(&self, anchor: LogAnchor) -> Result<(), NodeError> {
9047 let (applied_index, applied_hash) = self.ensure_materialized_tip()?;
9048 self.validate_read_barrier_descendant_from_tip(
9049 anchor,
9050 LogAnchor::new(applied_index, applied_hash),
9051 "materialized",
9052 )
9053 }
9054
9055 #[cfg(any(feature = "graph", feature = "kv"))]
9056 fn validate_read_barrier_qlog_descendant_locked(
9057 &self,
9058 anchor: LogAnchor,
9059 ) -> Result<(), NodeError> {
9060 let (qlog_index, qlog_hash) = self.durable_tip()?;
9061 self.validate_read_barrier_descendant_from_tip(
9062 anchor,
9063 LogAnchor::new(qlog_index, qlog_hash),
9064 "qlog",
9065 )
9066 }
9067
9068 fn validate_read_barrier_descendant_from_tip(
9069 &self,
9070 anchor: LogAnchor,
9071 tip: LogAnchor,
9072 tip_kind: &str,
9073 ) -> Result<(), NodeError> {
9074 if tip.index() < anchor.index() {
9075 return Err(self.latch(NodeError::Invariant(format!(
9076 "{tip_kind} tip {} precedes read barrier {}",
9077 tip.index(),
9078 anchor.index()
9079 ))));
9080 }
9081 if tip.index() == anchor.index() {
9082 if tip.hash() != anchor.hash() {
9083 return Err(self.latch(NodeError::Invariant(format!(
9084 "{tip_kind} tip hash differs from the read barrier anchor"
9085 ))));
9086 }
9087 return Ok(());
9088 }
9089 if anchor.index() == 0 {
9090 if anchor.hash() == LogHash::ZERO {
9091 return Ok(());
9092 }
9093 return Err(self.latch(NodeError::Invariant(
9094 "genesis read barrier anchor has a non-zero hash".into(),
9095 )));
9096 }
9097
9098 let logical = self
9099 .log_store
9100 .logical_state()
9101 .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9102 if let Some(compacted) = logical.anchor.as_ref().map(RecoveryAnchor::compacted) {
9103 if compacted.index() > anchor.index() {
9104 return Ok(());
9105 }
9106 if compacted.index() == anchor.index() {
9107 if compacted.hash() == anchor.hash() {
9108 return Ok(());
9109 }
9110 return Err(self.latch(NodeError::Invariant(
9111 "compacted qlog hash differs from the read barrier anchor".into(),
9112 )));
9113 }
9114 }
9115 let retained = self
9116 .log_store
9117 .read(anchor.index())
9118 .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?
9119 .ok_or_else(|| {
9120 self.latch(NodeError::Invariant(
9121 "read barrier anchor is neither retained nor compacted".into(),
9122 ))
9123 })?;
9124 if retained.hash != anchor.hash() {
9125 return Err(self.latch(NodeError::Invariant(
9126 "retained qlog hash differs from the read barrier anchor".into(),
9127 )));
9128 }
9129 Ok(())
9130 }
9131
9132 fn commit_read_barrier_locked(&self) -> Result<LogAnchor, NodeError> {
9133 self.ensure_writes_active()?;
9134 let context_read_fence = self.consensus.supports_context_read_fence();
9135 loop {
9136 self.ensure_ready()?;
9137 let (last_index, last_hash) = self.ensure_materialized_tip()?;
9138 let slot = last_index.checked_add(1).ok_or_else(|| {
9139 self.latch(NodeError::Invariant("qlog index is exhausted".into()))
9140 })?;
9141 let inspection = if context_read_fence {
9142 match self
9143 .consensus
9144 .inspect_context_read_fence_at(slot, last_hash)
9145 .map_err(|error| self.map_consensus_error(error))?
9146 {
9147 CertifiedDecisionInspection::Committed(certified) => {
9148 DecisionInspection::Committed(certified.entry)
9149 }
9150 CertifiedDecisionInspection::Empty => DecisionInspection::Empty,
9151 CertifiedDecisionInspection::Pending => DecisionInspection::Pending,
9152 CertifiedDecisionInspection::Unavailable => DecisionInspection::Unavailable,
9153 }
9154 } else {
9155 self.consensus
9156 .inspect_decision_at(slot, last_hash)
9157 .map_err(|error| self.map_consensus_error(error))?
9158 };
9159 match inspection {
9160 DecisionInspection::Committed(entry) => {
9161 self.persist_entry(&entry, slot, last_hash)?;
9162 }
9163 DecisionInspection::Empty if context_read_fence => {
9164 self.ensure_writes_active()?;
9168 return Ok(LogAnchor::new(last_index, last_hash));
9169 }
9170 DecisionInspection::Pending => {
9171 let entry = self
9172 .consensus
9173 .propose_at_cancellable(
9174 slot,
9175 last_hash,
9176 Command::new(CommandKind::ReadBarrier, Vec::new()),
9177 &self.operation_cancelled,
9178 )
9179 .map_err(|error| self.map_consensus_error(error))?;
9180 self.persist_entry(&entry, slot, last_hash)?;
9181 }
9185 DecisionInspection::Empty => {
9186 let entry = self
9187 .consensus
9188 .propose_at_cancellable(
9189 slot,
9190 last_hash,
9191 Command::new(CommandKind::ReadBarrier, Vec::new()),
9192 &self.operation_cancelled,
9193 )
9194 .map_err(|error| self.map_consensus_error(error))?;
9195 let is_barrier =
9196 entry.entry_type == EntryType::Noop && entry.payload.is_empty();
9197 self.persist_entry(&entry, slot, last_hash)?;
9198 if is_barrier {
9199 return Ok(LogAnchor::new(entry.index, entry.hash));
9200 }
9201 }
9202 DecisionInspection::Unavailable => {
9203 return Err(NodeError::Unavailable(
9204 "decision inspection did not reach quorum".into(),
9205 ));
9206 }
9207 }
9208 }
9209 }
9210
9211 #[cfg(feature = "sql")]
9212 fn check_request(
9213 &self,
9214 request_id: &str,
9215 payload: &[u8],
9216 ) -> Result<Option<RequestOutcome>, NodeError> {
9217 let sqlite = self.lock_sqlite()?;
9218 sqlite
9219 .check_request(request_id, payload)
9220 .map_err(|error| self.map_sqlite_error(error))
9221 }
9222
9223 #[cfg(feature = "graph")]
9224 #[cfg_attr(
9225 all(not(feature = "sql"), not(feature = "kv")),
9226 allow(irrefutable_let_patterns)
9227 )]
9228 fn check_graph_request(
9229 &self,
9230 request_id: &str,
9231 payload: &[u8],
9232 ) -> Result<Option<GraphRequestRecord>, NodeError> {
9233 let materializer = self.lock_materializer()?;
9234 let Materializer::Graph(graph) = &*materializer else {
9235 return Err(NodeError::ExecutionProfileMismatch {
9236 expected: ExecutionProfile::Graph,
9237 actual: materializer.profile(),
9238 });
9239 };
9240 graph
9241 .check_request(request_id, payload)
9242 .map_err(|error| NodeError::InvalidRequest(error.to_string()))
9243 }
9244
9245 #[cfg(feature = "kv")]
9246 #[cfg_attr(
9247 all(not(feature = "sql"), not(feature = "graph")),
9248 allow(irrefutable_let_patterns)
9249 )]
9250 fn check_kv_request(
9251 &self,
9252 request_id: &str,
9253 payload: &[u8],
9254 ) -> Result<Option<KvRequestRecord>, NodeError> {
9255 let materializer = self.lock_materializer()?;
9256 let Materializer::Kv(kv) = &*materializer else {
9257 return Err(NodeError::ExecutionProfileMismatch {
9258 expected: ExecutionProfile::Kv,
9259 actual: materializer.profile(),
9260 });
9261 };
9262 kv.check_request(request_id, payload)
9263 .map_err(|error| NodeError::InvalidRequest(error.to_string()))
9264 }
9265
9266 fn ensure_materialized_tip(&self) -> Result<(LogIndex, LogHash), NodeError> {
9267 #[cfg(test)]
9268 self.materialized_tip_checks.fetch_add(1, Ordering::Relaxed);
9269 let (last_index, last_hash) = self.durable_tip()?;
9270 let materializer = self.lock_materializer()?;
9271 let applied_tip = materializer
9272 .applied_tip()
9273 .map_err(|error| self.latch(NodeError::Storage(error)))?;
9274 let applied_index = applied_tip.index();
9275 let applied_hash = applied_tip.hash();
9276 if (applied_index, applied_hash) != (last_index, last_hash) {
9277 return Err(self.latch(NodeError::Invariant(format!(
9278 "qlog tip {last_index}/{} differs from {} materializer tip {applied_index}/{}",
9279 last_hash.to_hex(),
9280 materializer.profile(),
9281 applied_hash.to_hex()
9282 ))));
9283 }
9284 Ok((last_index, last_hash))
9285 }
9286
9287 fn durable_tip(&self) -> Result<(LogIndex, LogHash), NodeError> {
9288 static_log_tip(&self.log_store).map_err(|error| self.latch(error))
9289 }
9290
9291 fn persist_entry(
9292 &self,
9293 entry: &LogEntry,
9294 expected_index: LogIndex,
9295 expected_prev_hash: LogHash,
9296 ) -> Result<Option<SqlCommandResult>, NodeError> {
9297 let configuration_state = self.configuration_state()?;
9298 validate_runtime_entry(
9299 &self.config,
9300 &configuration_state,
9301 entry,
9302 expected_index,
9303 expected_prev_hash,
9304 )
9305 .map_err(|error| self.latch(error))?;
9306 if matches!(
9307 self.config.execution_profile,
9308 ExecutionProfile::Sqlite | ExecutionProfile::Kv
9309 ) {
9310 self.log_store
9313 .append_batch_buffered(std::slice::from_ref(entry))
9314 .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9315 } else {
9316 self.log_store
9317 .append(entry)
9318 .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9319 }
9320 self.lock_materializer()?
9321 .apply_entry(entry)
9322 .map_err(|error| self.latch(NodeError::Invariant(error)))
9323 }
9324
9325 #[cfg(feature = "sql")]
9326 fn persist_sql_entry_profiled<P: SqlWritePhaseProfile>(
9327 &self,
9328 entry: &LogEntry,
9329 expected_index: LogIndex,
9330 expected_prev_hash: LogHash,
9331 profile: &mut P,
9332 ) -> Result<Option<SqlCommandResult>, NodeError> {
9333 let configuration_state = self.configuration_state()?;
9334 validate_runtime_entry(
9335 &self.config,
9336 &configuration_state,
9337 entry,
9338 expected_index,
9339 expected_prev_hash,
9340 )
9341 .map_err(|error| self.latch(error))?;
9342
9343 let qlog_mark = profile.mark();
9344 let append_result = self
9345 .log_store
9346 .append_batch_buffered(std::slice::from_ref(entry))
9347 .map_err(|error| self.latch(NodeError::Storage(error.to_string())));
9348 profile.add_local_qlog_mirror_append(qlog_mark);
9349 append_result?;
9350
9351 let materializer_mark = profile.mark();
9352 let apply_result = self
9353 .lock_materializer()?
9354 .apply_entry(entry)
9355 .map_err(|error| self.latch(NodeError::Invariant(error)));
9356 profile.add_sql_materializer_apply(materializer_mark);
9357 apply_result
9358 }
9359
9360 fn require_execution_profile(&self, expected: ExecutionProfile) -> Result<(), NodeError> {
9361 if self.config.execution_profile == expected {
9362 Ok(())
9363 } else {
9364 Err(NodeError::ExecutionProfileMismatch {
9365 expected,
9366 actual: self.config.execution_profile,
9367 })
9368 }
9369 }
9370
9371 fn ensure_ready(&self) -> Result<(), NodeError> {
9372 if self.fatal.load(Ordering::Acquire) {
9373 return Err(NodeError::Fatal(
9374 self.fatal_reason()
9375 .unwrap_or_else(|| "fatal state is latched".into()),
9376 ));
9377 }
9378 if !self.ready.load(Ordering::Acquire) {
9379 return Err(NodeError::Unavailable("runtime is not ready".into()));
9380 }
9381 Ok(())
9382 }
9383
9384 fn ensure_writes_active(&self) -> Result<(), NodeError> {
9385 let state = self.configuration_state()?;
9386 if state.is_active() {
9387 Ok(())
9388 } else {
9389 Err(NodeError::ConfigurationTransition {
9390 state: Box::new(state),
9391 })
9392 }
9393 }
9394
9395 fn lock_commit(&self) -> Result<MutexGuard<'_, ()>, NodeError> {
9396 self.commit
9397 .lock()
9398 .map_err(|_| self.latch(NodeError::Invariant("commit mutex is poisoned".into())))
9399 }
9400
9401 fn lock_materializer(&self) -> Result<MutexGuard<'_, Materializer>, NodeError> {
9402 self.materializer.lock().map_err(|_| {
9403 self.latch(NodeError::Invariant(
9404 "materializer mutex is poisoned".into(),
9405 ))
9406 })
9407 }
9408
9409 #[cfg(feature = "graph")]
9410 #[cfg_attr(
9411 all(not(feature = "sql"), not(feature = "kv")),
9412 allow(irrefutable_let_patterns)
9413 )]
9414 fn graph_materializer(&self) -> Result<Arc<LadybugStateMachine>, NodeError> {
9415 let materializer = self.lock_materializer()?;
9416 let Materializer::Graph(graph) = &*materializer else {
9417 return Err(NodeError::ExecutionProfileMismatch {
9418 expected: ExecutionProfile::Graph,
9419 actual: materializer.profile(),
9420 });
9421 };
9422 Ok(Arc::clone(graph))
9423 }
9424
9425 #[cfg(feature = "kv")]
9426 #[cfg_attr(
9427 all(not(feature = "sql"), not(feature = "graph")),
9428 allow(irrefutable_let_patterns)
9429 )]
9430 fn kv_materializer(&self) -> Result<Arc<RedbStateMachine>, NodeError> {
9431 let materializer = self.lock_materializer()?;
9432 let Materializer::Kv(kv) = &*materializer else {
9433 return Err(NodeError::ExecutionProfileMismatch {
9434 expected: ExecutionProfile::Kv,
9435 actual: materializer.profile(),
9436 });
9437 };
9438 Ok(Arc::clone(kv))
9439 }
9440
9441 #[cfg(feature = "sql")]
9442 fn lock_sqlite(&self) -> Result<SqlMaterializerGuard<'_>, NodeError> {
9443 let guard = self.lock_materializer()?;
9444 if !matches!(&*guard, Materializer::Sql(_)) {
9445 return Err(NodeError::ExecutionProfileMismatch {
9446 expected: ExecutionProfile::Sqlite,
9447 actual: guard.profile(),
9448 });
9449 }
9450 Ok(SqlMaterializerGuard(guard))
9451 }
9452
9453 #[cfg(feature = "sql")]
9454 fn map_sqlite_error(&self, error: rhiza_sql::Error) -> NodeError {
9455 match error {
9456 rhiza_sql::Error::RequestConflict(conflict) => NodeError::RequestConflict(conflict),
9457 rhiza_sql::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9458 rhiza_sql::Error::InvalidCommand(message)
9459 | rhiza_sql::Error::IdentityMismatch(message)
9460 | rhiza_sql::Error::InvalidEntry(message)
9461 | rhiza_sql::Error::InvalidSnapshot(message) => {
9462 self.latch(NodeError::Invariant(message))
9463 }
9464 other => self.latch(NodeError::Storage(other.to_string())),
9465 }
9466 }
9467
9468 #[cfg(feature = "graph")]
9469 fn map_graph_read_error(&self, error: rhiza_graph::Error) -> NodeError {
9470 match error {
9471 rhiza_graph::Error::InvalidCommand(_) => NodeError::InvalidRequest(error.to_string()),
9472 rhiza_graph::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9473 rhiza_graph::Error::Ladybug(_) | rhiza_graph::Error::Io(_) => {
9474 self.latch(NodeError::Storage(error.to_string()))
9475 }
9476 rhiza_graph::Error::Closed
9477 | rhiza_graph::Error::Codec(_)
9478 | rhiza_graph::Error::InvalidEntry(_)
9479 | rhiza_graph::Error::IdentityMismatch(_)
9480 | rhiza_graph::Error::RequestConflict { .. }
9481 | rhiza_graph::Error::InvalidSnapshot(_) => {
9482 self.latch(NodeError::Invariant(error.to_string()))
9483 }
9484 }
9485 }
9486
9487 #[cfg(feature = "kv")]
9488 fn map_kv_read_error(&self, error: rhiza_kv::Error) -> NodeError {
9489 match error {
9490 rhiza_kv::Error::InvalidCommand(_) | rhiza_kv::Error::InvalidQuery(_) => {
9491 NodeError::InvalidRequest(error.to_string())
9492 }
9493 rhiza_kv::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9494 rhiza_kv::Error::Database(_) | rhiza_kv::Error::Io(_) => {
9495 self.latch(NodeError::Storage(error.to_string()))
9496 }
9497 rhiza_kv::Error::Codec(_)
9498 | rhiza_kv::Error::InvalidEntry(_)
9499 | rhiza_kv::Error::PartialInitialization
9500 | rhiza_kv::Error::RequestConflict { .. }
9501 | rhiza_kv::Error::InvalidSnapshot(_) => {
9502 self.latch(NodeError::Invariant(error.to_string()))
9503 }
9504 }
9505 }
9506
9507 fn map_consensus_error(&self, error: rhiza_quepaxa::Error) -> NodeError {
9508 match error {
9509 rhiza_quepaxa::Error::NoQuorum
9510 | rhiza_quepaxa::Error::ProposeFailed
9511 | rhiza_quepaxa::Error::CommandUnavailable
9512 | rhiza_quepaxa::Error::Cancelled
9513 | rhiza_quepaxa::Error::Io(_) => NodeError::Unavailable(error.to_string()),
9514 rhiza_quepaxa::Error::ConflictingCertificates
9515 | rhiza_quepaxa::Error::ChainConflict { .. } => {
9516 self.latch(NodeError::Reconciliation(error.to_string()))
9517 }
9518 other => self.latch(NodeError::Invariant(other.to_string())),
9519 }
9520 }
9521
9522 fn latch(&self, error: NodeError) -> NodeError {
9523 self.ready.store(false, Ordering::Release);
9524 if !self.fatal.swap(true, Ordering::AcqRel) {
9525 eprintln!(
9526 "node runtime entered fatal state: {}",
9527 escaped_error_detail(&error)
9528 );
9529 if let Ok(mut reason) = self.fatal_reason.lock() {
9530 *reason = Some(error.to_string());
9531 }
9532 }
9533 error
9534 }
9535}
9536
9537pub fn rehydrate_recorder_after_checkpoint(
9538 runtime: &NodeRuntime,
9539 recorder: &RecorderFileStore,
9540 checkpoint_index: LogIndex,
9541 startup: &StartupIoContext,
9542) -> Result<(), NodeError> {
9543 startup.check("recorder rehydration anchor validation")?;
9544 if let Some(anchor) = runtime
9545 .log_store()
9546 .logical_state()
9547 .map_err(|error| NodeError::Storage(error.to_string()))?
9548 .anchor
9549 {
9550 if checkpoint_index < anchor.compacted().index() {
9551 return Err(NodeError::SnapshotRequired(Box::new(anchor)));
9552 }
9553 }
9554 let applied_index = runtime.applied_index()?;
9555 if checkpoint_index > applied_index {
9556 return Err(NodeError::Reconciliation(format!(
9557 "checkpoint tip {checkpoint_index} is ahead of local applied index {applied_index}"
9558 )));
9559 }
9560
9561 for index in checkpoint_index.saturating_add(1)..=applied_index {
9562 startup.check("recorder rehydration qlog read")?;
9563 let entry = runtime
9564 .log_store()
9565 .read(index)
9566 .map_err(|error| NodeError::Storage(error.to_string()))?
9567 .ok_or_else(|| {
9568 NodeError::Reconciliation(format!(
9569 "qlog entry {index} is missing during recorder rehydration"
9570 ))
9571 })?;
9572 startup.check("recorder rehydration decision inspection")?;
9573 let certified = match runtime
9574 .consensus()
9575 .inspect_certified_decision_at(index, entry.prev_hash)
9576 .map_err(startup_consensus_error)?
9577 {
9578 CertifiedDecisionInspection::Committed(certified) => certified,
9579 CertifiedDecisionInspection::Empty => {
9580 return Err(NodeError::Reconciliation(format!(
9581 "qlog entry {index} has no recorder decision certificate"
9582 )))
9583 }
9584 CertifiedDecisionInspection::Pending => {
9585 return Err(NodeError::Reconciliation(format!(
9586 "qlog entry {index} has only a pending recorder decision"
9587 )))
9588 }
9589 CertifiedDecisionInspection::Unavailable => {
9590 return Err(NodeError::Unavailable(format!(
9591 "recorder decision certificate is unavailable at qlog index {index}"
9592 )))
9593 }
9594 };
9595 startup.check("recorder rehydration decision inspection")?;
9596 if certified.entry != entry {
9597 return Err(NodeError::Reconciliation(format!(
9598 "recorder decision certificate differs from qlog entry {index}"
9599 )));
9600 }
9601 let command = StoredCommand::new(entry.entry_type, entry.payload.clone());
9602 let proof = certified.proof.clone();
9603 startup.persist("recorder rehydration persistence", || {
9604 recorder
9605 .store_command(command.hash(), command)
9606 .map_err(|error| {
9607 NodeError::Reconciliation(format!(
9608 "cannot restore recorder command at qlog index {index}: {error}"
9609 ))
9610 })?;
9611 recorder
9612 .install_decision_proof_record(proof, runtime.consensus().membership())
9613 .map_err(|error| {
9614 NodeError::Reconciliation(format!(
9615 "cannot install recorder decision at qlog index {index}: {error}"
9616 ))
9617 })
9618 })?;
9619 }
9620 Ok(())
9621}
9622
9623#[cfg(test)]
9624mod tests {
9625 use axum::http::HeaderValue;
9626
9627 use rhiza_core::{
9628 Command, CommandKind, ConfigurationState, EntryType, ErrorCategory, ErrorClassification,
9629 ExecutionProfile, LogAnchor, LogHash, RecoveryAnchor, SnapshotIdentity, StoredCommand,
9630 };
9631 #[cfg(feature = "graph")]
9632 use rhiza_graph::{GraphCommandV1, GraphValueV1};
9633 #[cfg(feature = "kv")]
9634 use rhiza_kv::KvCommandV1;
9635 use rhiza_log::LogStore as _;
9636 use rhiza_quepaxa::{
9637 AcceptedValue, Membership, Proposal, ProposalPriority, RecordRequest, RecordSummary,
9638 RecorderFileStore, RecorderRpc, ThreeNodeConsensus,
9639 };
9640 use std::sync::{
9641 atomic::{AtomicBool, AtomicUsize, Ordering},
9642 mpsc, Arc, Barrier, Condvar, Mutex,
9643 };
9644
9645 #[cfg(any(feature = "sql", feature = "graph", feature = "kv"))]
9646 use super::node_error_response;
9647 #[cfg(feature = "graph")]
9648 use super::with_graph_client_permit;
9649 use super::ReadBarrierRounds;
9650 use super::{
9651 client_authenticated, next_sync_flush_retry, run_read_operation, sql_query_http_response,
9652 valid_recorder_record, Duration, FileLogStore, HeaderMap, Instant, NodeError,
9653 ReadConsistency, SqlCommand, SqlQueryResponse, SqlStatement, SqlValue, SqlWriteProfiler,
9654 MAX_COMMAND_BYTES, MAX_SQL_RESPONSE_BYTES, PROTOCOL_VERSION, QWAL_V3_MAGIC,
9655 SYNC_FLUSH_RETRY_INITIAL, VERSION_HEADER,
9656 };
9657 use super::{ConfigError, NodeConfig, NodeRuntime, NodeService, StartupIoContext};
9658
9659 struct BlockingStartupInspection {
9660 recorder: RecorderFileStore,
9661 started: mpsc::SyncSender<()>,
9662 gate: Arc<(Mutex<bool>, Condvar)>,
9663 }
9664
9665 impl RecorderRpc for BlockingStartupInspection {
9666 fn recorder_id(&self) -> rhiza_quepaxa::Result<String> {
9667 self.recorder.recorder_id()
9668 }
9669
9670 fn inspect_record_summary(
9671 &self,
9672 slot: u64,
9673 ) -> rhiza_quepaxa::Result<Option<RecordSummary>> {
9674 self.started.send(()).unwrap();
9675 let (released, condition) = &*self.gate;
9676 let mut released = released.lock().unwrap();
9677 while !*released {
9678 released = condition.wait(released).unwrap();
9679 }
9680 self.recorder.inspect_record_summary(slot)
9681 }
9682
9683 fn fetch_command_for(
9684 &self,
9685 cluster_id: String,
9686 epoch: u64,
9687 config_id: u64,
9688 config_digest: LogHash,
9689 command_hash: LogHash,
9690 ) -> rhiza_quepaxa::Result<Option<StoredCommand>> {
9691 self.recorder.fetch_command_for(
9692 cluster_id,
9693 epoch,
9694 config_id,
9695 config_digest,
9696 command_hash,
9697 )
9698 }
9699 }
9700
9701 #[test]
9702 fn runtime_open_does_not_persist_a_decision_returned_after_cancellation() {
9703 let root = tempfile::tempdir().unwrap();
9704 let node_ids = ["n1", "n2", "n3"];
9705 let membership = Membership::new(node_ids).unwrap();
9706 let recorders = node_ids
9707 .iter()
9708 .map(|node_id| {
9709 (
9710 (*node_id).to_owned(),
9711 RecorderFileStore::new_with_membership(
9712 root.path().join("recorders").join(node_id),
9713 *node_id,
9714 "rhiza:sql:startup-cancel-test",
9715 1,
9716 1,
9717 membership.clone(),
9718 )
9719 .unwrap(),
9720 )
9721 })
9722 .collect::<Vec<_>>();
9723 let seed_consensus = ThreeNodeConsensus::from_recorders_with_ids(
9724 "rhiza:sql:startup-cancel-test",
9725 "n1",
9726 1,
9727 1,
9728 recorders
9729 .iter()
9730 .map(|(id, recorder)| {
9731 (
9732 id.clone(),
9733 Box::new(recorder.clone()) as Box<dyn RecorderRpc>,
9734 )
9735 })
9736 .collect(),
9737 )
9738 .unwrap();
9739 seed_consensus
9740 .propose_at(
9741 1,
9742 LogHash::ZERO,
9743 Command::new(CommandKind::ReadBarrier, Vec::new()),
9744 )
9745 .unwrap();
9746 assert!(seed_consensus.finish_pending_rpcs(Duration::from_secs(1)));
9747 drop(seed_consensus);
9748
9749 let (started, blocked) = mpsc::sync_channel(3);
9750 let gate = Arc::new((Mutex::new(false), Condvar::new()));
9751 let consensus = Arc::new(
9752 ThreeNodeConsensus::from_recorders_with_ids(
9753 "rhiza:sql:startup-cancel-test",
9754 "n1",
9755 1,
9756 1,
9757 recorders
9758 .into_iter()
9759 .map(|(id, recorder)| {
9760 (
9761 id,
9762 Box::new(BlockingStartupInspection {
9763 recorder,
9764 started: started.clone(),
9765 gate: Arc::clone(&gate),
9766 }) as Box<dyn RecorderRpc>,
9767 )
9768 })
9769 .collect(),
9770 )
9771 .unwrap(),
9772 );
9773 let config = NodeConfig::new_embedded(
9774 "startup-cancel-test",
9775 "n1",
9776 root.path().join("node"),
9777 1,
9778 1,
9779 node_ids,
9780 )
9781 .unwrap();
9782 let startup = StartupIoContext::new();
9783 let attempt_startup = startup.clone();
9784 let attempt_config = config.clone();
9785 let attempt = std::thread::spawn(move || {
9786 NodeRuntime::open_cancellable(attempt_config, consensus, &[], &attempt_startup)
9787 });
9788
9789 blocked.recv().unwrap();
9790 startup.cancel(Instant::now() + Duration::from_secs(1));
9791 let (released, condition) = &*gate;
9792 *released.lock().unwrap() = true;
9793 condition.notify_all();
9794
9795 let error = attempt.join().unwrap().unwrap_err();
9796 assert!(
9797 error
9798 .to_string()
9799 .contains("startup cancelled during recorder decision inspection"),
9800 "{error}"
9801 );
9802 let log_store = FileLogStore::open_with_configuration(
9803 config.data_dir.join("consensus/log"),
9804 &config.cluster_id,
9805 config.epoch,
9806 config.log_initial_configuration.clone(),
9807 )
9808 .unwrap();
9809 assert_eq!(log_store.last_index().unwrap(), None);
9810 }
9811
9812 #[test]
9813 fn startup_cancellation_closes_mutation_admission_after_an_active_write_finishes() {
9814 let startup = StartupIoContext::new();
9815 let attempt_startup = startup.clone();
9816 let writes = Arc::new(AtomicUsize::new(0));
9817 let attempt_writes = Arc::clone(&writes);
9818 let (entered, active) = mpsc::sync_channel(1);
9819 let gate = Arc::new((Mutex::new(false), Condvar::new()));
9820 let attempt_gate = Arc::clone(&gate);
9821 let attempt = std::thread::spawn(move || {
9822 attempt_startup.persist("test persistence", || {
9823 entered.send(()).unwrap();
9824 let (released, condition) = &*attempt_gate;
9825 let mut released = released.lock().unwrap();
9826 while !*released {
9827 released = condition.wait(released).unwrap();
9828 }
9829 attempt_writes.fetch_add(1, Ordering::AcqRel);
9830 Ok(())
9831 })
9832 });
9833
9834 active.recv().unwrap();
9835 startup.cancel(Instant::now() + Duration::from_secs(1));
9836 let (released, condition) = &*gate;
9837 *released.lock().unwrap() = true;
9838 condition.notify_all();
9839 attempt.join().unwrap().unwrap();
9840
9841 assert!(startup
9842 .persist("late persistence", || {
9843 writes.fetch_add(1, Ordering::AcqRel);
9844 Ok(())
9845 })
9846 .is_err());
9847 assert_eq!(writes.load(Ordering::Acquire), 1);
9848 }
9849
9850 #[test]
9851 fn embedded_config_accepts_matching_canonical_profile_ids() {
9852 for (cluster_id, profile) in [
9853 ("rhiza:graph:embedded", ExecutionProfile::Graph),
9854 ("rhiza:kv:embedded", ExecutionProfile::Kv),
9855 ] {
9856 let config = NodeConfig::new_embedded(
9857 cluster_id,
9858 "n1",
9859 std::env::temp_dir().join(profile.as_str()),
9860 1,
9861 1,
9862 ["n1", "n2", "n3"],
9863 )
9864 .unwrap()
9865 .with_execution_profile(profile)
9866 .unwrap();
9867
9868 assert_eq!(config.cluster_id(), cluster_id);
9869 assert_eq!(config.logical_cluster_id(), "embedded");
9870 }
9871 }
9872
9873 #[test]
9874 fn embedded_config_rejects_conflicting_canonical_profile_and_preserves_logical_ids() {
9875 let conflicting = NodeConfig::new_embedded(
9876 "rhiza:graph:embedded",
9877 "n1",
9878 std::env::temp_dir().join("conflicting-profile"),
9879 1,
9880 1,
9881 ["n1", "n2", "n3"],
9882 )
9883 .unwrap()
9884 .with_execution_profile(ExecutionProfile::Sqlite)
9885 .unwrap_err();
9886 assert!(matches!(
9887 conflicting,
9888 ConfigError::ClusterIdProfileMismatch {
9889 expected: ExecutionProfile::Sqlite,
9890 actual: ExecutionProfile::Graph,
9891 }
9892 ));
9893
9894 let logical = NodeConfig::new_embedded(
9895 "embedded",
9896 "n1",
9897 std::env::temp_dir().join("logical-profile"),
9898 1,
9899 1,
9900 ["n1", "n2", "n3"],
9901 )
9902 .unwrap();
9903 assert_eq!(logical.cluster_id(), "rhiza:sql:embedded");
9904 assert_eq!(logical.logical_cluster_id(), "embedded");
9905 }
9906
9907 #[test]
9908 fn node_error_classification_reports_observable_retry_semantics() {
9909 let cases = [
9910 (
9911 NodeError::InvalidRequest("missing key".into()),
9912 "invalid_request",
9913 ErrorCategory::InvalidRequest,
9914 false,
9915 ),
9916 (
9917 NodeError::PreconditionFailed("stale version".into()),
9918 "precondition_failed",
9919 ErrorCategory::Conflict,
9920 false,
9921 ),
9922 (
9923 NodeError::Unavailable("no quorum".into()),
9924 "unavailable",
9925 ErrorCategory::Unavailable,
9926 true,
9927 ),
9928 (
9929 NodeError::ResourceExhausted("result too large".into()),
9930 "resource_exhausted",
9931 ErrorCategory::ResourceExhausted,
9932 true,
9933 ),
9934 (
9935 NodeError::Invariant("invalid log".into()),
9936 "invariant_violation",
9937 ErrorCategory::Internal,
9938 false,
9939 ),
9940 ];
9941
9942 for (error, code, category, retryable) in cases {
9943 let classification = error.classification();
9944
9945 assert_eq!(classification.code(), code);
9946 assert_eq!(classification.category(), category);
9947 assert_eq!(classification.retryable(), retryable);
9948 }
9949 }
9950
9951 #[cfg(feature = "sql")]
9952 #[test]
9953 fn sql_batch_error_classification_preserves_statement_index_category() {
9954 let error = NodeError::InvalidSqlStatement {
9955 statement_index: 3,
9956 message: "syntax error".into(),
9957 };
9958
9959 let classification = error.classification();
9960
9961 assert_eq!(classification.code(), "invalid_request");
9962 assert_eq!(classification.category(), ErrorCategory::InvalidRequest);
9963 assert!(!classification.retryable());
9964 }
9965
9966 #[cfg(feature = "sql")]
9967 #[tokio::test]
9968 async fn node_error_http_response_preserves_contract() {
9969 let snapshot = RecoveryAnchor::new(
9970 "cluster",
9971 1,
9972 ConfigurationState::active(1, LogHash::digest(&[b"node-error-test-config"])),
9973 1,
9974 LogAnchor::new(1, LogHash::ZERO),
9975 SnapshotIdentity::new(
9976 "snapshot",
9977 LogHash::digest(&[b"node-error-test-snapshot"]),
9978 0,
9979 rhiza_sql::sql_executor_fingerprint().unwrap(),
9980 ),
9981 );
9982 let cases = vec![
9983 (
9984 NodeError::InvalidSqlStatement {
9985 statement_index: 3,
9986 message: "syntax error".into(),
9987 },
9988 axum::http::StatusCode::BAD_REQUEST,
9989 "invalid_request",
9990 false,
9991 Some(3),
9992 ),
9993 (
9994 NodeError::PreconditionFailed("stale version".into()),
9995 axum::http::StatusCode::CONFLICT,
9996 "precondition_failed",
9997 false,
9998 None,
9999 ),
10000 (
10001 NodeError::SnapshotRequired(Box::new(snapshot)),
10002 axum::http::StatusCode::SERVICE_UNAVAILABLE,
10003 "snapshot_required",
10004 false,
10005 None,
10006 ),
10007 (
10008 NodeError::Storage("disk failed".into()),
10009 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
10010 "storage_error",
10011 false,
10012 None,
10013 ),
10014 ];
10015
10016 for (node_error, status, code, retryable, statement_index) in cases {
10017 let response = node_error_response(node_error);
10018 assert_eq!(response.status(), status);
10019 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10020 .await
10021 .unwrap();
10022 let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
10023 let error: super::ClientErrorResponse = serde_json::from_value(value.clone()).unwrap();
10024 assert_eq!(error.code, code);
10025 assert_eq!(error.retryable, retryable);
10026 assert_eq!(error.statement_index, statement_index);
10027 assert!(value.get("category").is_none());
10028 assert!(matches!(
10029 error.message.as_str(),
10030 "request could not be processed"
10031 | "request conflicts with current state"
10032 | "service is temporarily unavailable"
10033 | "internal server error"
10034 ));
10035 }
10036 }
10037
10038 #[cfg(feature = "sql")]
10039 #[tokio::test]
10040 async fn node_error_http_response_hides_display_details() {
10041 let detail = "/srv/rhiza/private/consensus/log: checksum mismatch";
10042 let cases = [
10043 (
10044 NodeError::Storage(detail.into()),
10045 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
10046 "storage_error",
10047 None,
10048 "internal server error",
10049 ),
10050 (
10051 NodeError::Reconciliation(detail.into()),
10052 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
10053 "reconciliation_error",
10054 None,
10055 "internal server error",
10056 ),
10057 (
10058 NodeError::InvalidSqlStatement {
10059 statement_index: 7,
10060 message: detail.into(),
10061 },
10062 axum::http::StatusCode::BAD_REQUEST,
10063 "invalid_request",
10064 Some(7),
10065 "request could not be processed",
10066 ),
10067 ];
10068
10069 for (node_error, status, code, statement_index, message) in cases {
10070 let response = node_error_response(node_error);
10071 assert_eq!(response.status(), status);
10072 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10073 .await
10074 .unwrap();
10075 let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10076 assert_eq!(error.code, code);
10077 assert_eq!(error.statement_index, statement_index);
10078 assert_eq!(error.message, message);
10079 assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
10080 }
10081 }
10082
10083 #[tokio::test]
10084 async fn client_json_error_response_uses_a_stable_message() {
10085 for (status, code) in [
10086 (axum::http::StatusCode::BAD_REQUEST, "invalid_json"),
10087 (
10088 axum::http::StatusCode::PAYLOAD_TOO_LARGE,
10089 "payload_too_large",
10090 ),
10091 ] {
10092 let response = super::client_json_error_response(status);
10093
10094 assert_eq!(response.status(), status);
10095 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10096 .await
10097 .unwrap();
10098 let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10099 assert_eq!(error.code, code);
10100 assert!(!error.retryable);
10101 assert_eq!(error.message, "request body is invalid");
10102 assert_eq!(error.statement_index, None);
10103 }
10104 }
10105
10106 #[tokio::test]
10107 async fn client_task_error_hides_join_error_details() {
10108 let detail = "private task detail\nforged log entry";
10109 let error = tokio::spawn(async move { panic!("{detail}") })
10110 .await
10111 .unwrap_err();
10112 let response = super::client_task_error(error);
10113
10114 assert_eq!(
10115 response.status(),
10116 axum::http::StatusCode::INTERNAL_SERVER_ERROR
10117 );
10118 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10119 .await
10120 .unwrap();
10121 let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10122 assert_eq!(error.code, "task_failed");
10123 assert!(!error.retryable);
10124 assert_eq!(error.message, "request task failed");
10125 assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
10126 }
10127
10128 #[cfg(feature = "sql")]
10129 #[tokio::test]
10130 async fn fatal_readiness_response_hides_fatal_reason() {
10131 let (_dir, runtime) = sql_test_runtime();
10132 let detail = "/srv/rhiza/private\nforged log entry";
10133 runtime.latch(NodeError::Storage(detail.into()));
10134 let response = super::runtime_readiness_response(&runtime).unwrap();
10135
10136 assert_eq!(
10137 response.status(),
10138 axum::http::StatusCode::INTERNAL_SERVER_ERROR
10139 );
10140 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10141 .await
10142 .unwrap();
10143 let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
10144 assert_eq!(error.code, "fatal");
10145 assert!(!error.retryable);
10146 assert_eq!(error.message, "node is fatally unavailable");
10147 assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
10148 }
10149
10150 #[test]
10151 fn escaped_error_detail_escapes_control_characters() {
10152 let detail = "checksum mismatch\nforged entry\r\u{1b}[2J";
10153
10154 assert_eq!(
10155 super::escaped_error_detail(&detail),
10156 r"checksum mismatch\nforged entry\r\u{1b}[2J"
10157 );
10158 }
10159
10160 #[test]
10161 fn escaped_error_detail_bounds_escape_expansion() {
10162 let detail = "\n".repeat(super::MAX_ESCAPED_ERROR_DETAIL_BYTES / 2 + 1);
10163 let escaped = super::escaped_error_detail(&detail);
10164
10165 assert!(
10166 escaped.len() <= super::MAX_ESCAPED_ERROR_DETAIL_BYTES,
10167 "escaped detail must stay within the log budget"
10168 );
10169 assert!(
10170 escaped.ends_with(super::ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER),
10171 "truncated details must be explicit"
10172 );
10173 assert!(!escaped.contains('\n'));
10174 }
10175
10176 #[tokio::test]
10177 async fn client_error_responses_preserve_payload_and_authentication_wire_codes() {
10178 for (status, code, retryable, category) in [
10179 (
10180 axum::http::StatusCode::PAYLOAD_TOO_LARGE,
10181 "payload_too_large",
10182 false,
10183 ErrorCategory::ResourceExhausted,
10184 ),
10185 (
10186 axum::http::StatusCode::UNAUTHORIZED,
10187 "unauthorized",
10188 false,
10189 ErrorCategory::Authentication,
10190 ),
10191 ] {
10192 let response =
10193 super::client_error_response(status, code, retryable, "request failed", None);
10194 assert_eq!(response.status(), status);
10195 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10196 .await
10197 .unwrap();
10198 let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
10199 let error: super::ClientErrorResponse = serde_json::from_value(value.clone()).unwrap();
10200 assert_eq!(error.code, code);
10201 assert_eq!(error.retryable, retryable);
10202 assert!(value.get("category").is_none());
10203 assert_eq!(
10204 ErrorClassification::from_server_code(code, retryable).category(),
10205 category
10206 );
10207 }
10208 }
10209
10210 #[test]
10211 fn concurrent_read_barriers_registered_before_cutoff_share_one_generation() {
10212 let rounds = ReadBarrierRounds::new(Duration::ZERO);
10213 let cancelled = AtomicBool::new(false);
10214 let participants = (0..4).map(|_| rounds.join().unwrap()).collect::<Vec<_>>();
10215 let generation = participants[0].generation();
10216 assert!(participants[0].is_leader());
10217 assert!(participants[1..]
10218 .iter()
10219 .all(|participant| !participant.is_leader() && participant.generation() == generation));
10220
10221 let calls = AtomicUsize::new(0);
10222 let mut publication = participants[0].publication().unwrap();
10223 publication.wait_turn(&cancelled).unwrap();
10224 publication.start(&cancelled).unwrap();
10225 let anchor = LogAnchor::new(7, LogHash::digest(&[b"shared-barrier"]));
10226 calls.fetch_add(1, Ordering::Relaxed);
10227 publication.publish(Ok(anchor));
10228
10229 assert_eq!(calls.load(Ordering::Relaxed), 1);
10230 for participant in &participants[1..] {
10231 assert_eq!(participant.wait(&cancelled).unwrap(), anchor);
10232 }
10233 }
10234
10235 #[test]
10236 fn read_barrier_arriving_after_running_cutoff_uses_next_generation() {
10237 let rounds = Arc::new(ReadBarrierRounds::new(Duration::ZERO));
10238 let cancelled = Arc::new(AtomicBool::new(false));
10239 let first = rounds.join().unwrap();
10240 let first_generation = first.generation();
10241 let (running_tx, running_rx) = mpsc::channel();
10242 let (release_tx, release_rx) = mpsc::channel();
10243 let first_cancelled = Arc::clone(&cancelled);
10244 let first_worker = std::thread::spawn(move || {
10245 let mut publication = first.publication().unwrap();
10246 publication.wait_turn(&first_cancelled).unwrap();
10247 publication.start(&first_cancelled).unwrap();
10248 running_tx.send(()).unwrap();
10249 release_rx.recv().unwrap();
10250 let anchor = LogAnchor::new(1, LogHash::digest(&[b"first"]));
10251 publication.publish(Ok(anchor));
10252 anchor
10253 });
10254 running_rx.recv().unwrap();
10255
10256 let late = rounds.join().unwrap();
10257 assert!(late.is_leader());
10258 assert_eq!(late.generation(), first_generation + 1);
10259 release_tx.send(()).unwrap();
10260 assert_eq!(first_worker.join().unwrap().index(), 1);
10261
10262 let mut publication = late.publication().unwrap();
10263 publication.wait_turn(&cancelled).unwrap();
10264 publication.start(&cancelled).unwrap();
10265 let second = LogAnchor::new(2, LogHash::digest(&[b"second"]));
10266 publication.publish(Ok(second));
10267 assert_eq!(late.wait(&cancelled).unwrap(), second);
10268 }
10269
10270 #[test]
10271 fn completed_read_barrier_is_not_reused_and_predecessor_failure_retries_independently() {
10272 let rounds = ReadBarrierRounds::new(Duration::ZERO);
10273 let cancelled = AtomicBool::new(false);
10274 let failed = rounds.join().unwrap();
10275 let failed_generation = failed.generation();
10276 let mut publication = failed.publication().unwrap();
10277 publication.wait_turn(&cancelled).unwrap();
10278 publication.start(&cancelled).unwrap();
10279 publication.publish(Err(NodeError::Unavailable("no quorum".into())));
10280 assert!(matches!(
10281 failed.wait(&cancelled),
10282 Err(NodeError::Unavailable(_))
10283 ));
10284
10285 let retry = rounds.join().unwrap();
10286 assert!(retry.is_leader());
10287 assert_eq!(retry.generation(), failed_generation + 1);
10288 let mut publication = retry.publication().unwrap();
10289 publication.wait_turn(&cancelled).unwrap();
10290 publication.start(&cancelled).unwrap();
10291 let anchor = LogAnchor::new(1, LogHash::digest(&[b"retry"]));
10292 publication.publish(Ok(anchor));
10293 assert_eq!(retry.wait(&cancelled).unwrap(), anchor);
10294
10295 let later = rounds.join().unwrap();
10296 assert!(later.is_leader());
10297 assert_eq!(later.generation(), retry.generation() + 1);
10298 }
10299
10300 #[test]
10301 fn read_barrier_leader_drop_and_global_cancel_wake_waiters() {
10302 let rounds = Arc::new(ReadBarrierRounds::new(Duration::ZERO));
10303 let cancelled = Arc::new(AtomicBool::new(false));
10304 let abandoned = rounds.join().unwrap();
10305 let follower = rounds.join().unwrap();
10306 drop(abandoned.publication().unwrap());
10307 assert!(matches!(
10308 follower.wait(&cancelled),
10309 Err(NodeError::Unavailable(_))
10310 ));
10311
10312 let leader = rounds.join().unwrap();
10313 let waiting = rounds.join().unwrap();
10314 let waiting_cancelled = Arc::clone(&cancelled);
10315 let waiter = std::thread::spawn(move || waiting.wait(&waiting_cancelled));
10316 cancelled.store(true, Ordering::Release);
10317 rounds.cancel_waiters();
10318 assert!(matches!(
10319 waiter.join().unwrap(),
10320 Err(NodeError::Unavailable(_))
10321 ));
10322 drop(leader.publication().unwrap());
10323 }
10324
10325 #[test]
10326 fn sql_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
10327 let (_dir, mut runtime) = sql_test_runtime();
10328 runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
10329 let runtime = Arc::new(runtime);
10330 let start = Arc::new(Barrier::new(4));
10331 let workers = (0..4)
10332 .map(|_| {
10333 let runtime = Arc::clone(&runtime);
10334 let start = Arc::clone(&start);
10335 std::thread::spawn(move || {
10336 start.wait();
10337 runtime.read("missing", ReadConsistency::ReadBarrier)
10338 })
10339 })
10340 .collect::<Vec<_>>();
10341
10342 let responses = workers
10343 .into_iter()
10344 .map(|worker| worker.join().unwrap().unwrap())
10345 .collect::<Vec<_>>();
10346 assert!(responses
10347 .iter()
10348 .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
10349 assert_eq!(runtime.log_store().last_index().unwrap(), None);
10350 }
10351
10352 #[test]
10353 fn read_barrier_anchor_remains_valid_when_materialized_tip_advances() {
10354 let (_dir, runtime) = sql_test_runtime();
10355 let anchor = runtime.establish_read_barrier().unwrap();
10356 let write = runtime.write("request-1", "alpha", "one").unwrap();
10357 assert!(write.applied_index > anchor.index());
10358
10359 let _commit = runtime.lock_commit().unwrap();
10360 runtime.ensure_ready().unwrap();
10361 runtime.ensure_writes_active().unwrap();
10362 runtime
10363 .validate_read_barrier_descendant_locked(anchor)
10364 .unwrap();
10365 let read = runtime.read_local("alpha", Some(anchor.index())).unwrap();
10366
10367 assert_eq!(read.value.as_deref(), Some("one"));
10368 assert_eq!(read.applied_index, write.applied_index);
10369 assert_eq!(read.hash, write.hash);
10370 }
10371
10372 #[test]
10373 fn checkpoint_publication_does_not_close_runtime_readiness_or_writes() {
10374 let (_dir, runtime) = sql_test_runtime();
10375 runtime.checkpointing.store(true, Ordering::Release);
10376
10377 assert!(runtime.is_ready());
10378 runtime.ensure_ready().unwrap();
10379 let write = runtime.write("request-1", "alpha", "one").unwrap();
10380
10381 assert_eq!(write.applied_index, 1);
10382 assert_eq!(
10383 runtime.read("alpha", ReadConsistency::Local).unwrap().value,
10384 Some("one".into())
10385 );
10386 }
10387
10388 #[cfg(feature = "graph")]
10389 #[test]
10390 fn graph_read_barrier_checks_materialized_tip_once_before_snapshot() {
10391 let (_dir, runtime) = graph_test_runtime();
10392
10393 let response = runtime
10394 .get_graph_document("missing", ReadConsistency::ReadBarrier)
10395 .unwrap();
10396
10397 assert_eq!(response.applied_index, 0);
10398 assert_eq!(response.hash, LogHash::ZERO);
10399 assert_eq!(runtime.materialized_tip_checks.load(Ordering::Relaxed), 1);
10400 }
10401
10402 #[cfg(feature = "graph")]
10403 #[test]
10404 fn graph_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
10405 let (_dir, mut runtime) = graph_test_runtime();
10406 runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
10407 let runtime = Arc::new(runtime);
10408 let start = Arc::new(Barrier::new(4));
10409 let workers = (0..4)
10410 .map(|_| {
10411 let runtime = Arc::clone(&runtime);
10412 let start = Arc::clone(&start);
10413 std::thread::spawn(move || {
10414 start.wait();
10415 runtime.get_graph_document("missing", ReadConsistency::ReadBarrier)
10416 })
10417 })
10418 .collect::<Vec<_>>();
10419
10420 let responses = workers
10421 .into_iter()
10422 .map(|worker| worker.join().unwrap().unwrap())
10423 .collect::<Vec<_>>();
10424 assert!(responses
10425 .iter()
10426 .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
10427 assert_eq!(runtime.log_store().last_index().unwrap(), None);
10428 }
10429
10430 #[cfg(feature = "graph")]
10431 #[test]
10432 fn graph_read_barrier_releases_commit_lock_before_backend_snapshot() {
10433 let (_dir, mut runtime) = graph_test_runtime();
10434 let initial = runtime
10435 .mutate_graph(
10436 GraphCommandV1::put_document(
10437 "request-1",
10438 "document-1",
10439 GraphValueV1::String("one".into()),
10440 )
10441 .unwrap(),
10442 )
10443 .unwrap();
10444 let entered = Arc::new(Barrier::new(2));
10445 let release = Arc::new(Barrier::new(2));
10446 runtime.read_barrier_before_snapshot_hook = Some(Arc::new({
10447 let entered = Arc::clone(&entered);
10448 let release = Arc::clone(&release);
10449 move || {
10450 entered.wait();
10451 release.wait();
10452 }
10453 }));
10454 let runtime = Arc::new(runtime);
10455 let reader = {
10456 let runtime = Arc::clone(&runtime);
10457 std::thread::spawn(move || {
10458 runtime.get_graph_document("document-1", ReadConsistency::ReadBarrier)
10459 })
10460 };
10461 entered.wait();
10462 let (advanced_tx, advanced_rx) = mpsc::channel();
10463 let writer = {
10464 let runtime = Arc::clone(&runtime);
10465 std::thread::spawn(move || {
10466 let outcome = runtime
10467 .mutate_graph(
10468 GraphCommandV1::put_document(
10469 "request-2",
10470 "document-1",
10471 GraphValueV1::String("two".into()),
10472 )
10473 .unwrap(),
10474 )
10475 .unwrap();
10476 advanced_tx
10477 .send((outcome.applied_index(), outcome.hash()))
10478 .unwrap();
10479 outcome
10480 })
10481 };
10482
10483 let advanced_before_snapshot = advanced_rx.recv_timeout(Duration::from_secs(2));
10484 release.wait();
10485 let written = writer.join().unwrap();
10486 let read = reader.join().unwrap().unwrap();
10487
10488 assert!(
10489 advanced_before_snapshot.is_ok(),
10490 "graph write must advance while the read is paused before its backend snapshot"
10491 );
10492 assert!(read.applied_index >= initial.applied_index());
10493 if read.applied_index == initial.applied_index() {
10494 assert_eq!(read.hash, initial.hash());
10495 }
10496 assert_eq!(read.applied_index, written.applied_index());
10497 assert_eq!(read.hash, written.hash());
10498 }
10499
10500 #[cfg(feature = "graph")]
10501 #[test]
10502 fn read_barrier_rejects_same_index_snapshot_with_different_hash() {
10503 let (_dir, runtime) = graph_test_runtime();
10504 let anchor = LogAnchor::new(7, LogHash::digest(&[b"barrier-anchor"]));
10505 let observed = LogAnchor::new(7, LogHash::digest(&[b"divergent-snapshot"]));
10506
10507 assert!(matches!(
10508 runtime.validate_read_barrier_snapshot(anchor, observed),
10509 Err(NodeError::Invariant(message))
10510 if message.contains("snapshot tip hash differs")
10511 ));
10512 assert!(runtime.is_fatal());
10513 }
10514
10515 #[cfg(feature = "kv")]
10516 #[test]
10517 fn kv_read_barrier_checks_materialized_tip_once_before_snapshot() {
10518 let (_dir, runtime) = kv_test_runtime();
10519
10520 let response = runtime
10521 .get_kv(b"missing", ReadConsistency::ReadBarrier)
10522 .unwrap();
10523
10524 assert_eq!(response.applied_index, 0);
10525 assert_eq!(response.hash, LogHash::ZERO);
10526 assert_eq!(runtime.materialized_tip_checks.load(Ordering::Relaxed), 1);
10527 }
10528
10529 #[cfg(feature = "kv")]
10530 #[test]
10531 fn kv_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
10532 let (_dir, mut runtime) = kv_test_runtime();
10533 runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
10534 let runtime = Arc::new(runtime);
10535 let start = Arc::new(Barrier::new(4));
10536 let workers = (0..4)
10537 .map(|_| {
10538 let runtime = Arc::clone(&runtime);
10539 let start = Arc::clone(&start);
10540 std::thread::spawn(move || {
10541 start.wait();
10542 runtime.get_kv(b"missing", ReadConsistency::ReadBarrier)
10543 })
10544 })
10545 .collect::<Vec<_>>();
10546
10547 let responses = workers
10548 .into_iter()
10549 .map(|worker| worker.join().unwrap().unwrap())
10550 .collect::<Vec<_>>();
10551 assert!(responses
10552 .iter()
10553 .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
10554 assert_eq!(runtime.log_store().last_index().unwrap(), None);
10555 }
10556
10557 #[cfg(feature = "kv")]
10558 #[test]
10559 fn kv_read_barrier_releases_commit_lock_before_backend_snapshot() {
10560 let (_dir, mut runtime) = kv_test_runtime();
10561 let initial = runtime
10562 .mutate_kv(KvCommandV1::put("request-1", b"key".to_vec(), b"one".to_vec()).unwrap())
10563 .unwrap();
10564 let entered = Arc::new(Barrier::new(2));
10565 let release = Arc::new(Barrier::new(2));
10566 runtime.read_barrier_before_snapshot_hook = Some(Arc::new({
10567 let entered = Arc::clone(&entered);
10568 let release = Arc::clone(&release);
10569 move || {
10570 entered.wait();
10571 release.wait();
10572 }
10573 }));
10574 let runtime = Arc::new(runtime);
10575 let reader = {
10576 let runtime = Arc::clone(&runtime);
10577 std::thread::spawn(move || runtime.get_kv(b"key", ReadConsistency::ReadBarrier))
10578 };
10579 entered.wait();
10580 let (advanced_tx, advanced_rx) = mpsc::channel();
10581 let writer = {
10582 let runtime = Arc::clone(&runtime);
10583 std::thread::spawn(move || {
10584 let outcome = runtime
10585 .mutate_kv(
10586 KvCommandV1::put("request-2", b"key".to_vec(), b"two".to_vec()).unwrap(),
10587 )
10588 .unwrap();
10589 advanced_tx
10590 .send((outcome.applied_index(), outcome.hash()))
10591 .unwrap();
10592 outcome
10593 })
10594 };
10595
10596 let advanced_before_snapshot = advanced_rx.recv_timeout(Duration::from_secs(2));
10597 release.wait();
10598 let written = writer.join().unwrap();
10599 let read = reader.join().unwrap().unwrap();
10600
10601 assert!(
10602 advanced_before_snapshot.is_ok(),
10603 "KV write must advance while the read is paused before its backend snapshot"
10604 );
10605 assert!(read.applied_index >= initial.applied_index());
10606 if read.applied_index == initial.applied_index() {
10607 assert_eq!(read.hash, initial.hash());
10608 }
10609 assert_eq!(read.applied_index, written.applied_index());
10610 assert_eq!(read.hash, written.hash());
10611 }
10612
10613 #[test]
10614 fn client_authentication_rejects_empty_expected_token() {
10615 let mut headers = HeaderMap::new();
10616 headers.insert(VERSION_HEADER, HeaderValue::from_static(PROTOCOL_VERSION));
10617 headers.insert("authorization", HeaderValue::from_static("Bearer "));
10618
10619 assert!(!client_authenticated(&headers, ""));
10620 }
10621
10622 #[test]
10623 fn recorder_record_rejects_oversized_inline_command() {
10624 let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
10625 let command = StoredCommand::new(
10626 EntryType::Command,
10627 vec![0_u8; MAX_COMMAND_BYTES.saturating_add(1)],
10628 );
10629 let request = RecordRequest {
10630 cluster_id: "rhiza:sql:node-unit-test".into(),
10631 epoch: 1,
10632 config_id: 1,
10633 config_digest: membership.digest(),
10634 slot: 1,
10635 step: 1,
10636 proposal: Proposal::new(
10637 ProposalPriority::MAX,
10638 "n1",
10639 1,
10640 AcceptedValue::from_command(
10641 "rhiza:sql:node-unit-test",
10642 1,
10643 1,
10644 1,
10645 LogHash::ZERO,
10646 &command,
10647 ),
10648 ),
10649 command: Some(command),
10650 };
10651
10652 assert!(!valid_recorder_record(&request));
10653 }
10654
10655 #[test]
10656 fn sync_flush_retry_doubles_to_a_jitter_free_cap() {
10657 let mut delay = SYNC_FLUSH_RETRY_INITIAL;
10658 let mut delays = Vec::new();
10659 for _ in 0..7 {
10660 delays.push(delay);
10661 delay = next_sync_flush_retry(delay);
10662 }
10663
10664 assert_eq!(
10665 delays,
10666 [50, 100, 200, 400, 800, 1_000, 1_000].map(Duration::from_millis)
10667 );
10668 }
10669
10670 #[test]
10671 fn blocking_operation_offloads_on_current_thread_runtime() {
10672 let runtime = tokio::runtime::Builder::new_current_thread()
10673 .build()
10674 .unwrap();
10675 runtime.block_on(async {
10676 let caller = std::thread::current().id();
10677 let worker = run_read_operation(ReadConsistency::Local, || std::thread::current().id())
10678 .await
10679 .unwrap();
10680
10681 assert_ne!(worker, caller);
10682 });
10683 }
10684
10685 #[test]
10686 fn blocking_operation_runs_inline_on_multi_thread_runtime() {
10687 let runtime = tokio::runtime::Builder::new_multi_thread()
10688 .worker_threads(2)
10689 .build()
10690 .unwrap();
10691 runtime.block_on(async {
10692 let caller = std::thread::current().id();
10693 let worker = run_read_operation(ReadConsistency::AppliedIndex(1), || {
10694 std::thread::current().id()
10695 })
10696 .await
10697 .unwrap();
10698
10699 assert_eq!(worker, caller);
10700 });
10701 }
10702
10703 #[test]
10704 fn read_barrier_offloads_on_multi_thread_runtime() {
10705 let runtime = tokio::runtime::Builder::new_multi_thread()
10706 .worker_threads(2)
10707 .build()
10708 .unwrap();
10709 runtime.block_on(async {
10710 let caller = std::thread::current().id();
10711 let worker =
10712 run_read_operation(ReadConsistency::ReadBarrier, || std::thread::current().id())
10713 .await
10714 .unwrap();
10715
10716 assert_ne!(worker, caller);
10717 });
10718 }
10719
10720 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10721 async fn node_service_adaptive_sql_read_returns_point_and_query_results() {
10722 let (_dir, runtime) = sql_test_runtime();
10723 let service = NodeService::new(Arc::new(runtime), None);
10724
10725 let point = service
10726 .read("missing", ReadConsistency::Local)
10727 .await
10728 .unwrap();
10729 let query = service
10730 .query(
10731 SqlStatement {
10732 sql: "SELECT ?1 AS value".into(),
10733 parameters: vec![SqlValue::Integer(7)],
10734 },
10735 ReadConsistency::AppliedIndex(0),
10736 1,
10737 )
10738 .await
10739 .unwrap();
10740
10741 assert_eq!(point.value, None);
10742 assert_eq!(query.columns, vec!["value"]);
10743 assert_eq!(query.rows, vec![vec![SqlValue::Integer(7)]]);
10744 }
10745
10746 #[test]
10747 fn node_service_adaptive_sql_read_stays_inline_and_recovers_direct_panic() {
10748 let (_dir, runtime) = sql_test_runtime();
10749 let service = NodeService::new(Arc::new(runtime), None);
10750 let runtime = tokio::runtime::Builder::new_multi_thread()
10751 .worker_threads(2)
10752 .build()
10753 .unwrap();
10754 runtime.block_on(async {
10755 let caller = std::thread::current().id();
10756 let worker = service
10757 .run_sql_read_operation(ReadConsistency::Local, || std::thread::current().id())
10758 .await
10759 .unwrap();
10760
10761 assert_eq!(worker, caller);
10762 assert_eq!(
10763 service
10764 .sql_reads_in_flight
10765 .load(std::sync::atomic::Ordering::Acquire),
10766 0
10767 );
10768 });
10769
10770 let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
10771 runtime.block_on(
10772 service.run_sql_read_operation(ReadConsistency::AppliedIndex(0), || -> () {
10773 panic!("inline SQL read panic")
10774 }),
10775 )
10776 }));
10777 assert!(panic.is_err());
10778 assert_eq!(
10779 service
10780 .sql_reads_in_flight
10781 .load(std::sync::atomic::Ordering::Acquire),
10782 0
10783 );
10784 }
10785
10786 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10787 async fn node_service_adaptive_sql_read_offloads_overlap_and_recovers_join_error() {
10788 let (_dir, runtime) = sql_test_runtime();
10789 let service = NodeService::new(Arc::new(runtime), None);
10790 let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
10791 let (release_tx, release_rx) = std::sync::mpsc::channel();
10792 let first_service = service.clone();
10793 let first = tokio::spawn(async move {
10794 first_service
10795 .run_sql_read_operation(ReadConsistency::Local, move || {
10796 entered_tx.send(()).unwrap();
10797 release_rx.recv().unwrap();
10798 })
10799 .await
10800 .unwrap();
10801 });
10802 entered_rx.await.unwrap();
10803 assert_eq!(
10804 service
10805 .sql_reads_in_flight
10806 .load(std::sync::atomic::Ordering::Acquire),
10807 1
10808 );
10809
10810 let caller = std::thread::current().id();
10811 let worker = service
10812 .run_sql_read_operation(ReadConsistency::AppliedIndex(0), || {
10813 std::thread::current().id()
10814 })
10815 .await
10816 .unwrap();
10817 assert_ne!(worker, caller);
10818 assert_eq!(
10819 service
10820 .sql_reads_in_flight
10821 .load(std::sync::atomic::Ordering::Acquire),
10822 1
10823 );
10824
10825 let error = service
10826 .run_sql_read_operation(ReadConsistency::Local, || -> () {
10827 panic!("contended SQL read panic")
10828 })
10829 .await
10830 .unwrap_err();
10831 assert!(error.is_panic());
10832 assert_eq!(
10833 service
10834 .sql_reads_in_flight
10835 .load(std::sync::atomic::Ordering::Acquire),
10836 1
10837 );
10838
10839 release_tx.send(()).unwrap();
10840 first.await.unwrap();
10841 assert_eq!(
10842 service
10843 .sql_reads_in_flight
10844 .load(std::sync::atomic::Ordering::Acquire),
10845 0
10846 );
10847 }
10848
10849 #[test]
10850 fn node_service_adaptive_sql_read_offloads_on_current_thread() {
10851 let (_dir, node) = sql_test_runtime();
10852 let service = NodeService::new(Arc::new(node), None);
10853 let runtime = tokio::runtime::Builder::new_current_thread()
10854 .build()
10855 .unwrap();
10856 runtime.block_on(async {
10857 let caller = std::thread::current().id();
10858 let worker = service
10859 .run_sql_read_operation(ReadConsistency::Local, || std::thread::current().id())
10860 .await
10861 .unwrap();
10862
10863 assert_ne!(worker, caller);
10864 assert_eq!(
10865 service
10866 .sql_reads_in_flight
10867 .load(std::sync::atomic::Ordering::Acquire),
10868 0
10869 );
10870 });
10871 }
10872
10873 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10874 async fn node_service_read_barrier_offloads_without_counting_fast_reads() {
10875 let (_dir, runtime) = sql_test_runtime();
10876 let service = NodeService::new(Arc::new(runtime), None);
10877 let caller = std::thread::current().id();
10878 let worker = service
10879 .run_sql_read_operation(ReadConsistency::ReadBarrier, || std::thread::current().id())
10880 .await
10881 .unwrap();
10882
10883 assert_ne!(worker, caller);
10884 assert_eq!(
10885 service
10886 .sql_reads_in_flight
10887 .load(std::sync::atomic::Ordering::Acquire),
10888 0
10889 );
10890 }
10891
10892 #[test]
10893 fn embedded_sql_query_keeps_raw_budget_without_http_json_budget() {
10894 let (_dir, runtime) = sql_test_runtime();
10895 let response = runtime
10896 .query_sql(
10897 &SqlStatement {
10898 sql: "SELECT replace(hex(zeroblob(700000)), '00', char(1)) AS value".into(),
10899 parameters: Vec::new(),
10900 },
10901 ReadConsistency::Local,
10902 1,
10903 )
10904 .unwrap();
10905
10906 assert!(serde_json::to_vec(&response).unwrap().len() > MAX_SQL_RESPONSE_BYTES);
10907 }
10908
10909 #[test]
10910 fn sql_http_response_rejects_encoded_body_over_limit() {
10911 let response = SqlQueryResponse {
10912 columns: vec!["value".into()],
10913 rows: vec![vec![SqlValue::Text("\u{1}".repeat(700_000))]],
10914 applied_index: 0,
10915 hash: LogHash::ZERO,
10916 };
10917
10918 assert_eq!(
10919 sql_query_http_response(response).status(),
10920 axum::http::StatusCode::BAD_REQUEST
10921 );
10922 }
10923
10924 #[cfg(feature = "graph")]
10925 #[test]
10926 fn graph_response_work_holds_client_capacity_until_completion() {
10927 let slots = std::sync::Arc::new(tokio::sync::Semaphore::new(1));
10928 let permit = std::sync::Arc::new(slots.clone().try_acquire_owned().unwrap());
10929
10930 let capacity_exhausted_during_response =
10931 with_graph_client_permit(permit, || slots.clone().try_acquire_owned().is_err());
10932
10933 assert!(capacity_exhausted_during_response);
10934 assert!(slots.try_acquire().is_ok());
10935 }
10936
10937 #[cfg(feature = "graph")]
10938 #[test]
10939 fn graph_client_query_error_returns_400_without_latching_readiness() {
10940 let (_dir, runtime) = graph_test_runtime();
10941
10942 let error = runtime.map_graph_read_error(rhiza_graph::Error::InvalidCommand(
10943 "unknown property".into(),
10944 ));
10945 let response = node_error_response(error);
10946
10947 assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
10948 assert!(runtime.is_ready());
10949 assert!(!runtime.is_fatal());
10950 }
10951
10952 #[cfg(feature = "graph")]
10953 #[test]
10954 fn graph_resource_exhaustion_returns_503_without_latching_readiness() {
10955 let (_dir, runtime) = graph_test_runtime();
10956
10957 let error = runtime.map_graph_read_error(rhiza_graph::Error::ResourceExhausted(
10958 "buffer pool is full".into(),
10959 ));
10960 let response = node_error_response(error);
10961
10962 assert_eq!(
10963 response.status(),
10964 axum::http::StatusCode::SERVICE_UNAVAILABLE
10965 );
10966 assert!(runtime.is_ready());
10967 assert!(!runtime.is_fatal());
10968 }
10969
10970 #[cfg(feature = "kv")]
10971 #[test]
10972 fn kv_resource_exhaustion_returns_503_without_latching_readiness() {
10973 let (_dir, runtime) = kv_test_runtime();
10974
10975 let error = runtime.map_kv_read_error(rhiza_kv::Error::ResourceExhausted(
10976 "scan result is too large".into(),
10977 ));
10978 let response = node_error_response(error);
10979
10980 assert_eq!(
10981 response.status(),
10982 axum::http::StatusCode::SERVICE_UNAVAILABLE
10983 );
10984 assert!(runtime.is_ready());
10985 assert!(!runtime.is_fatal());
10986 }
10987
10988 #[cfg(feature = "graph")]
10989 #[test]
10990 fn graph_batch_coalesces_exact_retry_and_isolates_conflicting_duplicate() {
10991 let (_dir, runtime) = graph_test_runtime();
10992 let canonical =
10993 GraphCommandV1::put_document("same", "document", GraphValueV1::String("first".into()))
10994 .unwrap();
10995 let conflict = GraphCommandV1::put_document(
10996 "same",
10997 "document",
10998 GraphValueV1::String("conflict".into()),
10999 )
11000 .unwrap();
11001 let unrelated =
11002 GraphCommandV1::put_document("other", "other", GraphValueV1::U64(2)).unwrap();
11003 let results = runtime
11004 .mutate_graph_batch(vec![canonical.clone(), canonical, conflict, unrelated])
11005 .unwrap();
11006
11007 let canonical = results[0].as_ref().unwrap().applied_index();
11008 assert_eq!(results[1].as_ref().unwrap().applied_index(), canonical);
11009 assert!(matches!(
11010 results[2],
11011 Err(super::NodeError::InvalidRequest(_))
11012 ));
11013 assert_eq!(results[3].as_ref().unwrap().applied_index(), canonical);
11014 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11015 assert!(runtime.is_ready());
11016 }
11017
11018 #[cfg(feature = "kv")]
11019 #[test]
11020 fn kv_batch_coalesces_exact_retry_and_isolates_conflicting_duplicate() {
11021 let (_dir, runtime) = kv_test_runtime();
11022 let canonical = KvCommandV1::put("same", b"key".to_vec(), b"first".to_vec()).unwrap();
11023 let conflict = KvCommandV1::put("same", b"key".to_vec(), b"conflict".to_vec()).unwrap();
11024 let unrelated = KvCommandV1::put("other", b"other".to_vec(), b"second".to_vec()).unwrap();
11025 let results = runtime
11026 .mutate_kv_batch(vec![canonical.clone(), canonical, conflict, unrelated])
11027 .unwrap();
11028
11029 let canonical = results[0].as_ref().unwrap().applied_index();
11030 assert_eq!(results[1].as_ref().unwrap().applied_index(), canonical);
11031 assert!(matches!(
11032 results[2],
11033 Err(super::NodeError::InvalidRequest(_))
11034 ));
11035 assert_eq!(results[3].as_ref().unwrap().applied_index(), canonical);
11036 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11037 assert!(runtime.is_ready());
11038 }
11039
11040 #[cfg(feature = "kv")]
11041 #[test]
11042 fn kv_group_commit_coalesces_four_waiting_64_member_calls_into_one_qlog() {
11043 let (_dir, runtime) = kv_test_runtime();
11044 let runtime = Arc::new(runtime);
11045 let commit = runtime.lock_commit().unwrap();
11046 let start = Arc::new(Barrier::new(5));
11047 let workers = (0..4)
11048 .map(|call| {
11049 let runtime = Arc::clone(&runtime);
11050 let start = Arc::clone(&start);
11051 std::thread::spawn(move || {
11052 let commands = (0..64)
11053 .map(|member| {
11054 let id = call * 64 + member;
11055 KvCommandV1::put(
11056 format!("kv-group-{id}"),
11057 format!("key-{id}").into_bytes(),
11058 vec![u8::try_from(call).unwrap(); 128],
11059 )
11060 .unwrap()
11061 })
11062 .collect();
11063 start.wait();
11064 runtime.mutate_kv_batch(commands)
11065 })
11066 })
11067 .collect::<Vec<_>>();
11068 start.wait();
11069 runtime
11070 .kv_group_commit
11071 .wait_for_pending_calls(4, Duration::from_secs(5));
11072 drop(commit);
11073
11074 let responses = workers
11075 .into_iter()
11076 .map(|worker| worker.join().unwrap().unwrap())
11077 .collect::<Vec<_>>();
11078 let anchors = responses
11079 .iter()
11080 .flatten()
11081 .map(|result| {
11082 let outcome = result.as_ref().unwrap();
11083 (outcome.applied_index(), outcome.hash())
11084 })
11085 .collect::<std::collections::HashSet<_>>();
11086 assert_eq!(anchors.len(), 1);
11087 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11088 }
11089
11090 #[cfg(feature = "kv")]
11091 #[test]
11092 fn kv_group_commit_rejects_public_257_member_call_before_writing() {
11093 let (_dir, runtime) = kv_test_runtime();
11094 let commands = (0..257)
11095 .map(|id| {
11096 KvCommandV1::put(
11097 format!("kv-over-{id}"),
11098 format!("key-{id}").into_bytes(),
11099 b"value".to_vec(),
11100 )
11101 .unwrap()
11102 })
11103 .collect();
11104
11105 let error = runtime.mutate_kv_batch(commands).unwrap_err();
11106
11107 assert!(matches!(error, NodeError::InvalidRequest(_)));
11108 assert_eq!(runtime.log_store().last_index().unwrap(), None);
11109 assert!(runtime
11110 .kv_group_commit
11111 .state
11112 .lock()
11113 .unwrap()
11114 .pending
11115 .is_empty());
11116 }
11117
11118 #[cfg(feature = "kv")]
11119 #[test]
11120 fn kv_group_commit_lone_call_completes_and_leaves_queue_idle() {
11121 let (_dir, runtime) = kv_test_runtime();
11122
11123 let outcome = runtime
11124 .mutate_kv(KvCommandV1::put("kv-lone", b"key".to_vec(), b"value".to_vec()).unwrap())
11125 .unwrap();
11126
11127 assert_eq!(outcome.applied_index(), 1);
11128 let state = runtime
11129 .kv_group_commit
11130 .state
11131 .lock()
11132 .unwrap_or_else(std::sync::PoisonError::into_inner);
11133 assert!(state.pending.is_empty());
11134 assert_eq!(state.pending_encoded_bytes, 0);
11135 assert!(!state.leader_active);
11136 }
11137
11138 #[cfg(feature = "kv")]
11139 #[test]
11140 fn kv_group_commit_shutdown_wakes_waiters_without_writing() {
11141 let (_dir, runtime) = kv_test_runtime();
11142 let runtime = Arc::new(runtime);
11143 let commit = runtime.lock_commit().unwrap();
11144 let start = Arc::new(Barrier::new(3));
11145 let workers = (0..2)
11146 .map(|id| {
11147 let runtime = Arc::clone(&runtime);
11148 let start = Arc::clone(&start);
11149 std::thread::spawn(move || {
11150 start.wait();
11151 runtime.mutate_kv(
11152 KvCommandV1::put(
11153 format!("kv-shutdown-{id}"),
11154 format!("key-{id}").into_bytes(),
11155 b"value".to_vec(),
11156 )
11157 .unwrap(),
11158 )
11159 })
11160 })
11161 .collect::<Vec<_>>();
11162 start.wait();
11163 runtime
11164 .kv_group_commit
11165 .wait_for_pending_calls(2, Duration::from_secs(5));
11166
11167 runtime.cancel_operations();
11168 drop(commit);
11169
11170 for worker in workers {
11171 assert!(matches!(
11172 worker.join().unwrap(),
11173 Err(NodeError::Unavailable(_))
11174 ));
11175 }
11176 assert_eq!(runtime.log_store().last_index().unwrap(), None);
11177 let state = runtime
11178 .kv_group_commit
11179 .state
11180 .lock()
11181 .unwrap_or_else(std::sync::PoisonError::into_inner);
11182 assert!(state.pending.is_empty());
11183 assert_eq!(state.pending_encoded_bytes, 0);
11184 assert!(!state.leader_active);
11185 }
11186
11187 #[cfg(feature = "kv")]
11188 #[test]
11189 fn kv_group_commit_preserves_cross_call_retry_conflict_and_new_result_offsets() {
11190 let (_dir, runtime) = kv_test_runtime();
11191 let runtime = Arc::new(runtime);
11192 let stored = KvCommandV1::put(
11193 "kv-stored",
11194 b"stored-key".to_vec(),
11195 b"stored-value".to_vec(),
11196 )
11197 .unwrap();
11198 let stored_outcome = runtime.mutate_kv(stored.clone()).unwrap();
11199 let conflict =
11200 KvCommandV1::put("kv-stored", b"stored-key".to_vec(), b"conflict".to_vec()).unwrap();
11201 let commit = runtime.lock_commit().unwrap();
11202 let start = Arc::new(Barrier::new(3));
11203 let retry_worker = {
11204 let runtime = Arc::clone(&runtime);
11205 let start = Arc::clone(&start);
11206 std::thread::spawn(move || {
11207 start.wait();
11208 runtime.mutate_kv_batch(vec![stored, conflict])
11209 })
11210 };
11211 let new_worker = {
11212 let runtime = Arc::clone(&runtime);
11213 let start = Arc::clone(&start);
11214 std::thread::spawn(move || {
11215 start.wait();
11216 runtime.mutate_kv_batch(vec![
11217 KvCommandV1::put("kv-new-1", b"new-1".to_vec(), b"one".to_vec()).unwrap(),
11218 KvCommandV1::put("kv-new-2", b"new-2".to_vec(), b"two".to_vec()).unwrap(),
11219 ])
11220 })
11221 };
11222 start.wait();
11223 runtime
11224 .kv_group_commit
11225 .wait_for_pending_calls(2, Duration::from_secs(5));
11226 drop(commit);
11227
11228 let retry_results = retry_worker.join().unwrap().unwrap();
11229 let new_results = new_worker.join().unwrap().unwrap();
11230
11231 assert_eq!(
11232 retry_results[0].as_ref().unwrap().applied_index(),
11233 stored_outcome.applied_index()
11234 );
11235 assert!(matches!(
11236 retry_results[1],
11237 Err(NodeError::InvalidRequest(_))
11238 ));
11239 let new_anchors = new_results
11240 .iter()
11241 .map(|result| {
11242 let outcome = result.as_ref().unwrap();
11243 (outcome.applied_index(), outcome.hash())
11244 })
11245 .collect::<std::collections::HashSet<_>>();
11246 assert_eq!(new_anchors.len(), 1);
11247 assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11248 }
11249
11250 #[cfg(feature = "kv")]
11251 #[test]
11252 fn kv_group_commit_releases_pending_byte_budget_after_drain_and_failure() {
11253 let queue = super::KvGroupCommitQueue::new();
11254 let cancelled = AtomicBool::new(false);
11255 let member = |id: usize, bytes: usize| super::RuntimeBatchMember {
11256 #[cfg(feature = "sql")]
11257 request_id: format!("kv-byte-{id}"),
11258 payload: vec![u8::try_from(id).unwrap_or_default(); bytes],
11259 operation: super::QueuedOperation::Kv(
11260 KvCommandV1::put(
11261 format!("kv-byte-{id}"),
11262 format!("key-{id}").into_bytes(),
11263 b"value".to_vec(),
11264 )
11265 .unwrap(),
11266 ),
11267 };
11268 for id in 0..63 {
11269 queue
11270 .enqueue(vec![member(id, MAX_COMMAND_BYTES)], &cancelled)
11271 .unwrap();
11272 }
11273
11274 let overflow = match queue.enqueue(vec![member(63, MAX_COMMAND_BYTES * 2)], &cancelled) {
11275 Ok(_) => panic!("pending KV byte budget must reject oversized aggregate work"),
11276 Err(error) => error,
11277 };
11278 assert!(matches!(overflow, NodeError::ResourceExhausted(_)));
11279
11280 let drained = queue.drain_next_group().unwrap();
11281 assert_eq!(drained.len(), 63);
11282 let released = queue
11283 .enqueue(vec![member(64, MAX_COMMAND_BYTES)], &cancelled)
11284 .unwrap()
11285 .0;
11286 queue.fail_pending(NodeError::Unavailable("test failure".into()));
11287 assert!(matches!(
11288 released.wait(&cancelled),
11289 Err(NodeError::Unavailable(_))
11290 ));
11291 let state = queue
11292 .state
11293 .lock()
11294 .unwrap_or_else(std::sync::PoisonError::into_inner);
11295 assert!(state.pending.is_empty());
11296 assert_eq!(state.pending_encoded_bytes, 0);
11297 assert!(!state.leader_active);
11298 }
11299
11300 #[cfg(feature = "kv")]
11301 #[test]
11302 fn kv_group_commit_window_restarts_after_staggered_enqueue() {
11303 let queue = Arc::new(super::KvGroupCommitQueue::new());
11304 let cancelled = AtomicBool::new(false);
11305 let member = |id: usize| super::RuntimeBatchMember {
11306 #[cfg(feature = "sql")]
11307 request_id: format!("kv-debounce-{id}"),
11308 payload: vec![u8::try_from(id).unwrap_or_default()],
11309 operation: super::QueuedOperation::Kv(
11310 KvCommandV1::put(
11311 format!("kv-debounce-{id}"),
11312 format!("key-{id}").into_bytes(),
11313 b"value".to_vec(),
11314 )
11315 .unwrap(),
11316 ),
11317 };
11318 queue.enqueue(vec![member(1)], &cancelled).unwrap();
11319 let collector = Arc::clone(&queue);
11320 let (finished, receive) = std::sync::mpsc::channel();
11321 let worker = std::thread::spawn(move || {
11322 let collected = collector.collect_until_full_or_timeout(Duration::from_millis(100));
11323 finished.send(collected).unwrap();
11324 });
11325
11326 std::thread::sleep(Duration::from_millis(75));
11327 queue.enqueue(vec![member(2)], &cancelled).unwrap();
11328 std::thread::sleep(Duration::from_millis(75));
11329 queue.enqueue(vec![member(3)], &cancelled).unwrap();
11330 assert!(receive.recv_timeout(Duration::from_millis(50)).is_err());
11331 assert!(receive.recv_timeout(Duration::from_millis(150)).unwrap());
11332 worker.join().unwrap();
11333 assert_eq!(queue.drain_next_group().unwrap().len(), 3);
11334 }
11335
11336 #[cfg(feature = "kv")]
11337 #[test]
11338 fn kv_group_commit_returns_committed_group_when_cancelled_after_execution() {
11339 let (_dir, mut runtime) = kv_test_runtime();
11340 runtime.kv_group_commit_after_execute_hook = Some(Arc::new(NodeRuntime::cancel_operations));
11341 let runtime = Arc::new(runtime);
11342 let commit = runtime.lock_commit().unwrap();
11343 let start = Arc::new(Barrier::new(3));
11344 let workers = (0..2)
11345 .map(|call| {
11346 let runtime = Arc::clone(&runtime);
11347 let start = Arc::clone(&start);
11348 std::thread::spawn(move || {
11349 let commands = (0..64)
11350 .map(|member| {
11351 let id = call * 64 + member;
11352 KvCommandV1::put(
11353 format!("kv-cancel-after-{id}"),
11354 format!("key-{id}").into_bytes(),
11355 b"value".to_vec(),
11356 )
11357 .unwrap()
11358 })
11359 .collect();
11360 start.wait();
11361 runtime.mutate_kv_batch(commands)
11362 })
11363 })
11364 .collect::<Vec<_>>();
11365 start.wait();
11366 runtime
11367 .kv_group_commit
11368 .wait_for_pending_calls(2, Duration::from_secs(5));
11369 drop(commit);
11370
11371 let results = workers
11372 .into_iter()
11373 .flat_map(|worker| worker.join().unwrap().unwrap())
11374 .collect::<Vec<_>>();
11375
11376 assert_eq!(results.len(), 128);
11377 let anchors = results
11378 .iter()
11379 .map(|result| {
11380 let outcome = result.as_ref().unwrap();
11381 (outcome.applied_index(), outcome.hash())
11382 })
11383 .collect::<std::collections::HashSet<_>>();
11384 assert_eq!(anchors.len(), 1);
11385 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11386 assert!(runtime.operation_cancelled.load(Ordering::Acquire));
11387 }
11388
11389 #[cfg(feature = "kv")]
11390 #[test]
11391 fn kv_largest_fitting_prefix_is_exact_for_large_grouped_batch() {
11392 let commands = (0..256)
11393 .map(|id| {
11394 KvCommandV1::put(
11395 format!("kv-prefix-{id:04}"),
11396 format!("key-{id:04}").into_bytes(),
11397 vec![b'x'; 4 * 1024],
11398 )
11399 .unwrap()
11400 })
11401 .collect::<Vec<_>>();
11402 assert!(super::encode_replicated_kv_batch(&commands).unwrap().len() > MAX_COMMAND_BYTES);
11403 let expected = (2..commands.len())
11404 .filter(|count| {
11405 super::encode_replicated_kv_batch(&commands[..*count])
11406 .unwrap()
11407 .len()
11408 <= MAX_COMMAND_BYTES
11409 })
11410 .max()
11411 .unwrap();
11412
11413 let (count, payload) = super::largest_fitting_kv_batch_prefix(&commands).unwrap();
11414
11415 assert_eq!(count, expected);
11416 assert!(payload.len() <= MAX_COMMAND_BYTES);
11417 assert!(
11418 super::encode_replicated_kv_batch(&commands[..count + 1])
11419 .unwrap()
11420 .len()
11421 > MAX_COMMAND_BYTES
11422 );
11423 }
11424
11425 #[cfg(feature = "kv")]
11426 #[test]
11427 fn kv_group_commit_large_batch_uses_largest_fitting_fifo_sub_batches() {
11428 let (_dir, runtime) = kv_test_runtime();
11429 let runtime = Arc::new(runtime);
11430 let calls = (0..4)
11431 .map(|call| {
11432 (0..64)
11433 .map(|member| {
11434 let id = call * 64 + member;
11435 KvCommandV1::put(
11436 format!("kv-large-{id:04}"),
11437 format!("key-{id:04}").into_bytes(),
11438 vec![b'x'; 4 * 1024],
11439 )
11440 .unwrap()
11441 })
11442 .collect::<Vec<_>>()
11443 })
11444 .collect::<Vec<_>>();
11445 let flattened = calls.iter().flatten().cloned().collect::<Vec<_>>();
11446 let (largest_prefix, _) = super::largest_fitting_kv_batch_prefix(&flattened).unwrap();
11447 let expected_entries = flattened.len().div_ceil(largest_prefix);
11448 let commit = runtime.lock_commit().unwrap();
11449 let start = Arc::new(Barrier::new(5));
11450 let workers = calls
11451 .into_iter()
11452 .map(|commands| {
11453 let runtime = Arc::clone(&runtime);
11454 let start = Arc::clone(&start);
11455 std::thread::spawn(move || {
11456 start.wait();
11457 runtime.mutate_kv_batch(commands)
11458 })
11459 })
11460 .collect::<Vec<_>>();
11461 start.wait();
11462 runtime
11463 .kv_group_commit
11464 .wait_for_pending_calls(4, Duration::from_secs(5));
11465 drop(commit);
11466
11467 let mut counts = std::collections::BTreeMap::new();
11468 for result in workers
11469 .into_iter()
11470 .flat_map(|worker| worker.join().unwrap().unwrap())
11471 {
11472 *counts
11473 .entry(result.unwrap().applied_index())
11474 .or_insert(0_usize) += 1;
11475 }
11476
11477 assert_eq!(counts.len(), expected_entries);
11478 let counts = counts.into_values().collect::<Vec<_>>();
11479 assert!(counts[..counts.len() - 1]
11480 .iter()
11481 .all(|count| *count == largest_prefix));
11482 assert_eq!(counts.iter().sum::<usize>(), 256);
11483 assert_eq!(
11484 runtime.log_store().last_index().unwrap(),
11485 Some(u64::try_from(expected_entries).unwrap())
11486 );
11487 for index in 1..=u64::try_from(expected_entries).unwrap() {
11488 assert!(runtime
11489 .log_store()
11490 .read(index)
11491 .unwrap()
11492 .is_some_and(|entry| entry.payload.len() <= MAX_COMMAND_BYTES));
11493 }
11494 }
11495
11496 #[cfg(feature = "kv")]
11497 #[test]
11498 fn kv_group_commit_leader_panic_wakes_active_and_pending_calls_with_same_fatal() {
11499 let (_dir, mut runtime) = kv_test_runtime();
11500 runtime.kv_group_commit_before_execute_hook =
11501 Some(Arc::new(|| panic!("injected KV group leader panic")));
11502 let runtime = Arc::new(runtime);
11503 let commit = runtime.lock_commit().unwrap();
11504 let mut workers = Vec::new();
11505 for call in 0..17 {
11506 let worker_runtime = Arc::clone(&runtime);
11507 workers.push(std::thread::spawn(move || {
11508 worker_runtime.mutate_kv_batch(
11509 (0..64)
11510 .map(|member| {
11511 let id = call * 64 + member;
11512 KvCommandV1::put(
11513 format!("kv-panic-{id}"),
11514 format!("key-{id}").into_bytes(),
11515 b"value".to_vec(),
11516 )
11517 .unwrap()
11518 })
11519 .collect(),
11520 )
11521 }));
11522 runtime
11523 .kv_group_commit
11524 .wait_for_pending_calls(call + 1, Duration::from_secs(5));
11525 }
11526 drop(commit);
11527
11528 let errors = workers
11529 .into_iter()
11530 .map(|worker| worker.join().unwrap().unwrap_err())
11531 .collect::<Vec<_>>();
11532 assert!(errors
11533 .iter()
11534 .all(|error| matches!(error, NodeError::Fatal(_))));
11535 assert_eq!(errors[0].to_string(), errors[1].to_string());
11536 assert!(runtime.is_fatal());
11537 assert_eq!(runtime.log_store().last_index().unwrap(), None);
11538 }
11539
11540 #[cfg(feature = "kv")]
11541 #[test]
11542 fn kv_group_commit_reopens_all_four_grouped_calls_at_shared_durable_anchor() {
11543 let (dir, runtime) = kv_test_runtime();
11544 let runtime = Arc::new(runtime);
11545 let commit = runtime.lock_commit().unwrap();
11546 let start = Arc::new(Barrier::new(5));
11547 let workers = (0..4)
11548 .map(|call| {
11549 let runtime = Arc::clone(&runtime);
11550 let start = Arc::clone(&start);
11551 std::thread::spawn(move || {
11552 let commands = (0..64)
11553 .map(|member| {
11554 let id = call * 64 + member;
11555 KvCommandV1::put(
11556 format!("kv-reopen-{id}"),
11557 format!("key-{id:04}").into_bytes(),
11558 vec![u8::try_from(call).unwrap(); 128],
11559 )
11560 .unwrap()
11561 })
11562 .collect();
11563 start.wait();
11564 runtime.mutate_kv_batch(commands)
11565 })
11566 })
11567 .collect::<Vec<_>>();
11568 start.wait();
11569 runtime
11570 .kv_group_commit
11571 .wait_for_pending_calls(4, Duration::from_secs(5));
11572 drop(commit);
11573
11574 let results = workers
11575 .into_iter()
11576 .flat_map(|worker| worker.join().unwrap().unwrap())
11577 .collect::<Vec<_>>();
11578 let anchors = results
11579 .iter()
11580 .map(|result| {
11581 let outcome = result.as_ref().unwrap();
11582 (outcome.applied_index(), outcome.hash())
11583 })
11584 .collect::<std::collections::HashSet<_>>();
11585 let [(applied_index, applied_hash)] = anchors.into_iter().collect::<Vec<_>>()[..] else {
11586 panic!("four grouped calls must share one durable anchor");
11587 };
11588 assert_eq!(
11589 runtime.log_store().last_index().unwrap(),
11590 Some(applied_index)
11591 );
11592 let config = runtime.config().clone();
11593 drop(runtime);
11594
11595 let consensus = Arc::new(
11596 ThreeNodeConsensus::from_recovered_tip(
11597 config.cluster_id().to_owned(),
11598 config.node_id().to_owned(),
11599 config.epoch(),
11600 config.config_id(),
11601 [
11602 dir.path().join("recorders/n1"),
11603 dir.path().join("recorders/n2"),
11604 dir.path().join("recorders/n3"),
11605 ],
11606 applied_index + 1,
11607 applied_hash,
11608 )
11609 .unwrap(),
11610 );
11611 let reopened = NodeRuntime::open(config, consensus, &[]).unwrap();
11612
11613 assert_eq!(reopened.applied_index().unwrap(), applied_index);
11614 assert_eq!(reopened.applied_hash().unwrap(), applied_hash);
11615 assert_eq!(reopened.log_store().last_index().unwrap(), Some(1));
11616 for id in 0..256 {
11617 let response = reopened
11618 .get_kv(format!("key-{id:04}").as_bytes(), ReadConsistency::Local)
11619 .unwrap();
11620 assert_eq!(
11621 response.value,
11622 Some(vec![u8::try_from(id / 64).unwrap(); 128])
11623 );
11624 assert_eq!(response.applied_index, applied_index);
11625 assert_eq!(response.hash, applied_hash);
11626 }
11627 }
11628
11629 #[test]
11630 fn sql_batch_preflight_rejects_entire_vector_without_growing_log() {
11631 let (_dir, runtime) = sql_test_runtime();
11632 let valid = SqlCommand {
11633 request_id: "valid".into(),
11634 statements: vec![SqlStatement {
11635 sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY)".into(),
11636 parameters: vec![],
11637 }],
11638 };
11639 let invalid = SqlCommand {
11640 request_id: String::new(),
11641 statements: valid.statements.clone(),
11642 };
11643
11644 let error = runtime.execute_sql_batch(vec![valid, invalid]).unwrap_err();
11645
11646 assert!(matches!(error, NodeError::InvalidRequest(_)));
11647 assert_eq!(runtime.log_store().last_index().unwrap(), None);
11648 }
11649
11650 #[test]
11651 fn sql_batch_rejects_aggregate_encoded_input_over_command_cap_before_io() {
11652 let (_dir, runtime) = sql_test_runtime();
11653 let command = |request_id: &str, fill: char| SqlCommand {
11654 request_id: request_id.into(),
11655 statements: vec![SqlStatement {
11656 sql: "SELECT ?1".into(),
11657 parameters: vec![SqlValue::Text(
11658 std::iter::repeat_n(fill, MAX_COMMAND_BYTES / 2).collect(),
11659 )],
11660 }],
11661 };
11662
11663 let error = runtime
11664 .execute_sql_batch(vec![
11665 command("aggregate-a", 'a'),
11666 command("aggregate-b", 'b'),
11667 ])
11668 .unwrap_err();
11669
11670 assert!(matches!(error, NodeError::ResourceExhausted(_)));
11671 assert_eq!(runtime.log_store().last_index().unwrap(), None);
11672 assert!(runtime
11673 .sql_group_commit
11674 .state
11675 .lock()
11676 .unwrap()
11677 .pending
11678 .is_empty());
11679 }
11680
11681 #[test]
11682 fn sql_write_profiling_records_nothing_when_observer_is_not_installed() {
11683 let profiler = SqlWriteProfiler::new(8);
11684 let (_dir, runtime) = sql_test_runtime();
11685
11686 runtime
11687 .execute_sql(SqlCommand {
11688 request_id: "schema".into(),
11689 statements: vec![SqlStatement {
11690 sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11691 parameters: vec![],
11692 }],
11693 })
11694 .unwrap();
11695
11696 assert!(runtime.config().sql_write_profiler().is_none());
11697 assert!(profiler.snapshot().samples.is_empty());
11698 }
11699
11700 #[test]
11701 fn sql_write_profiling_records_one_consistent_sample_for_one_physical_batch() {
11702 let profiler = SqlWriteProfiler::new(8);
11703 let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11704 runtime
11705 .execute_sql(SqlCommand {
11706 request_id: "schema".into(),
11707 statements: vec![SqlStatement {
11708 sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11709 parameters: vec![],
11710 }],
11711 })
11712 .unwrap();
11713 profiler.drain();
11714
11715 let commands = (1..=3)
11716 .map(|id| SqlCommand {
11717 request_id: format!("insert-{id}"),
11718 statements: vec![SqlStatement {
11719 sql: "INSERT INTO profiled_items(id) VALUES (?1)".into(),
11720 parameters: vec![SqlValue::Integer(id)],
11721 }],
11722 })
11723 .collect();
11724 let responses = runtime.execute_sql_batch(commands).unwrap();
11725
11726 let snapshot = profiler.snapshot();
11727 assert_eq!(snapshot.dropped_samples, 0);
11728 let [sample] = snapshot.samples.as_slice() else {
11729 panic!("one physical SQL batch must emit one sample: {snapshot:?}");
11730 };
11731 assert_eq!(sample.batch_member_count, 3);
11732 assert_eq!(
11733 sample.total_service_us,
11734 sample
11735 .commit_lock_wait_us
11736 .saturating_add(sample.precheck_classification_us)
11737 .saturating_add(sample.qwal_prepare_us)
11738 .saturating_add(sample.consensus_propose_us)
11739 .saturating_add(sample.local_qlog_mirror_append_us)
11740 .saturating_add(sample.sql_materializer_apply_us)
11741 .saturating_add(sample.response_other_total_us)
11742 );
11743 let (applied_index, applied_hash) = runtime.ensure_materialized_tip().unwrap();
11744 assert!(responses.iter().all(|response| {
11745 response.as_ref().is_ok_and(|response| {
11746 response.applied_index == applied_index && response.hash == applied_hash
11747 })
11748 }));
11749 }
11750
11751 #[test]
11752 fn sql_write_profiling_does_not_fabricate_sample_for_failed_batch() {
11753 let profiler = SqlWriteProfiler::new(8);
11754 let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11755 runtime
11756 .execute_sql(SqlCommand {
11757 request_id: "schema".into(),
11758 statements: vec![SqlStatement {
11759 sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11760 parameters: vec![],
11761 }],
11762 })
11763 .unwrap();
11764 runtime
11765 .execute_sql(SqlCommand {
11766 request_id: "first".into(),
11767 statements: vec![SqlStatement {
11768 sql: "INSERT INTO profiled_items(id) VALUES (1)".into(),
11769 parameters: vec![],
11770 }],
11771 })
11772 .unwrap();
11773 profiler.drain();
11774 let last_index = runtime.log_store().last_index().unwrap();
11775
11776 let error = runtime
11777 .execute_sql(SqlCommand {
11778 request_id: "duplicate".into(),
11779 statements: vec![SqlStatement {
11780 sql: "INSERT INTO profiled_items(id) VALUES (1)".into(),
11781 parameters: vec![],
11782 }],
11783 })
11784 .unwrap_err();
11785
11786 assert!(matches!(error, NodeError::InvalidSqlStatement { .. }));
11787 assert!(profiler.snapshot().samples.is_empty());
11788 assert_eq!(runtime.log_store().last_index().unwrap(), last_index);
11789 }
11790
11791 #[test]
11792 fn sql_group_commit_coalesces_four_waiting_typed_calls_into_one_1024_receipt_qwal() {
11793 let profiler = SqlWriteProfiler::new(8);
11794 let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11795 let runtime = Arc::new(runtime);
11796 runtime
11797 .execute_sql(SqlCommand {
11798 request_id: "group-schema".into(),
11799 statements: vec![SqlStatement {
11800 sql: "CREATE TABLE grouped_items(id INTEGER PRIMARY KEY)".into(),
11801 parameters: vec![],
11802 }],
11803 })
11804 .unwrap();
11805 profiler.drain();
11806
11807 let commit = runtime.lock_commit().unwrap();
11808 let start = Arc::new(Barrier::new(5));
11809 let workers = (0..4)
11810 .map(|call| {
11811 let runtime = Arc::clone(&runtime);
11812 let start = Arc::clone(&start);
11813 std::thread::spawn(move || {
11814 let commands = (0..256)
11815 .map(|offset| {
11816 let id = call * 256 + offset;
11817 SqlCommand {
11818 request_id: format!("group-{id}"),
11819 statements: vec![SqlStatement {
11820 sql: "INSERT INTO grouped_items(id) VALUES (?1)".into(),
11821 parameters: vec![SqlValue::Integer(id)],
11822 }],
11823 }
11824 })
11825 .collect();
11826 start.wait();
11827 runtime.execute_sql_batch(commands).unwrap()
11828 })
11829 })
11830 .collect::<Vec<_>>();
11831 start.wait();
11832 runtime
11833 .sql_group_commit
11834 .wait_for_pending_calls(4, Duration::from_secs(5));
11835 drop(commit);
11836
11837 let results = workers
11838 .into_iter()
11839 .flat_map(|worker| worker.join().unwrap())
11840 .collect::<Vec<_>>();
11841 assert_eq!(results.len(), 1024);
11842 let first = results[0].as_ref().unwrap();
11843 assert!(results.iter().all(|result| {
11844 result.as_ref().is_ok_and(|result| {
11845 result.applied_index == first.applied_index && result.hash == first.hash
11846 })
11847 }));
11848 let entry = runtime
11849 .log_store()
11850 .read(first.applied_index)
11851 .unwrap()
11852 .unwrap();
11853 assert_eq!(
11854 rhiza_sql::decode_qwal_v3(&entry.payload)
11855 .unwrap()
11856 .receipts
11857 .len(),
11858 1024
11859 );
11860 assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11861 let snapshot = profiler.snapshot();
11862 let [sample] = snapshot.samples.as_slice() else {
11863 panic!("one grouped physical commit must emit one sample: {snapshot:?}");
11864 };
11865 assert_eq!(sample.batch_member_count, 1024);
11866 }
11867
11868 #[test]
11869 fn sql_group_commit_keeps_fifth_whole_call_for_the_next_physical_group() {
11870 let (_dir, runtime) = sql_test_runtime();
11871 let runtime = Arc::new(runtime);
11872 runtime
11873 .execute_sql(SqlCommand {
11874 request_id: "next-group-schema".into(),
11875 statements: vec![SqlStatement {
11876 sql: "CREATE TABLE next_group_items(id INTEGER PRIMARY KEY)".into(),
11877 parameters: vec![],
11878 }],
11879 })
11880 .unwrap();
11881
11882 let commit = runtime.lock_commit().unwrap();
11883 let mut workers = Vec::new();
11884 for call in 0..5 {
11885 let worker_runtime = Arc::clone(&runtime);
11886 workers.push(std::thread::spawn(move || {
11887 worker_runtime
11888 .execute_sql_batch(
11889 (0..256)
11890 .map(|offset| {
11891 let id = call * 256 + offset;
11892 SqlCommand {
11893 request_id: format!("next-group-{id}"),
11894 statements: vec![SqlStatement {
11895 sql: "INSERT INTO next_group_items(id) VALUES (?1)".into(),
11896 parameters: vec![SqlValue::Integer(id)],
11897 }],
11898 }
11899 })
11900 .collect(),
11901 )
11902 .unwrap()
11903 }));
11904 runtime
11905 .sql_group_commit
11906 .wait_for_pending_calls(call as usize + 1, Duration::from_secs(5));
11907 }
11908 drop(commit);
11909
11910 let calls = workers
11911 .into_iter()
11912 .map(|worker| worker.join().unwrap())
11913 .collect::<Vec<_>>();
11914 for call in &calls[..4] {
11915 assert!(call
11916 .iter()
11917 .all(|result| result.as_ref().unwrap().applied_index == 2));
11918 }
11919 assert!(calls[4]
11920 .iter()
11921 .all(|result| result.as_ref().unwrap().applied_index == 3));
11922 assert_eq!(
11923 rhiza_sql::decode_qwal_v3(&runtime.log_store().read(2).unwrap().unwrap().payload)
11924 .unwrap()
11925 .receipts
11926 .len(),
11927 1024
11928 );
11929 assert_eq!(
11930 rhiza_sql::decode_qwal_v3(&runtime.log_store().read(3).unwrap().unwrap().payload)
11931 .unwrap()
11932 .receipts
11933 .len(),
11934 256
11935 );
11936 }
11937
11938 #[test]
11939 fn sql_group_commit_preserves_fifo_call_offsets_for_retries_conflicts_aliases_and_failures() {
11940 let (_dir, runtime) = sql_test_runtime();
11941 let runtime = Arc::new(runtime);
11942 runtime
11943 .execute_sql(SqlCommand {
11944 request_id: "fifo-schema".into(),
11945 statements: vec![SqlStatement {
11946 sql: "CREATE TABLE fifo_items(id INTEGER PRIMARY KEY, value TEXT UNIQUE)"
11947 .into(),
11948 parameters: vec![],
11949 }],
11950 })
11951 .unwrap();
11952 let stored_command = SqlCommand {
11953 request_id: "fifo-stored".into(),
11954 statements: vec![SqlStatement {
11955 sql: "INSERT INTO fifo_items(id, value) VALUES (1, 'stored')".into(),
11956 parameters: vec![],
11957 }],
11958 };
11959 let stored = runtime.execute_sql(stored_command.clone()).unwrap();
11960 let valid_alias = SqlCommand {
11961 request_id: "fifo-alias".into(),
11962 statements: vec![SqlStatement {
11963 sql: "INSERT INTO fifo_items(id, value) VALUES (2, 'alias')".into(),
11964 parameters: vec![],
11965 }],
11966 };
11967 let conflict = SqlCommand {
11968 request_id: stored_command.request_id.clone(),
11969 statements: vec![SqlStatement {
11970 sql: "INSERT INTO fifo_items(id, value) VALUES (3, 'conflict')".into(),
11971 parameters: vec![],
11972 }],
11973 };
11974 let failed = SqlCommand {
11975 request_id: "fifo-failed".into(),
11976 statements: vec![SqlStatement {
11977 sql: "INSERT INTO fifo_items(id, value) VALUES (4, 'stored')".into(),
11978 parameters: vec![],
11979 }],
11980 };
11981 let valid = SqlCommand {
11982 request_id: "fifo-valid".into(),
11983 statements: vec![SqlStatement {
11984 sql: "INSERT INTO fifo_items(id, value) VALUES (5, 'valid')".into(),
11985 parameters: vec![],
11986 }],
11987 };
11988
11989 let commit = runtime.lock_commit().unwrap();
11990 let first_runtime = Arc::clone(&runtime);
11991 let first = std::thread::spawn(move || {
11992 first_runtime
11993 .execute_sql_batch(vec![
11994 stored_command,
11995 conflict,
11996 valid_alias.clone(),
11997 valid_alias,
11998 ])
11999 .unwrap()
12000 });
12001 runtime
12002 .sql_group_commit
12003 .wait_for_pending_calls(1, Duration::from_secs(5));
12004 let second_runtime = Arc::clone(&runtime);
12005 let second = std::thread::spawn(move || {
12006 second_runtime
12007 .execute_sql_batch(vec![failed, valid])
12008 .unwrap()
12009 });
12010 runtime
12011 .sql_group_commit
12012 .wait_for_pending_calls(2, Duration::from_secs(5));
12013 drop(commit);
12014
12015 let first = first.join().unwrap();
12016 let second = second.join().unwrap();
12017 assert_eq!(
12018 first[0].as_ref().unwrap().applied_index,
12019 stored.applied_index
12020 );
12021 assert!(matches!(first[1], Err(NodeError::RequestConflict(_))));
12022 assert_eq!(first[2], first[3]);
12023 assert_eq!(first[2].as_ref().unwrap().applied_index, 3);
12024 assert!(matches!(
12025 second[0],
12026 Err(NodeError::InvalidSqlStatement { .. })
12027 ));
12028 assert_eq!(second[1].as_ref().unwrap().applied_index, 3);
12029 }
12030
12031 #[test]
12032 fn sql_group_commit_rejects_overload_before_enqueue_without_orphaning_the_leader() {
12033 let (_dir, runtime) = sql_test_runtime_configured(None, Some(1));
12034 let runtime = Arc::new(runtime);
12035 runtime
12036 .execute_sql(SqlCommand {
12037 request_id: "overload-schema".into(),
12038 statements: vec![SqlStatement {
12039 sql: "CREATE TABLE overload_items(id INTEGER PRIMARY KEY)".into(),
12040 parameters: vec![],
12041 }],
12042 })
12043 .unwrap();
12044 let command = |request_id: &str, id| SqlCommand {
12045 request_id: request_id.into(),
12046 statements: vec![SqlStatement {
12047 sql: "INSERT INTO overload_items(id) VALUES (?1)".into(),
12048 parameters: vec![SqlValue::Integer(id)],
12049 }],
12050 };
12051
12052 let commit = runtime.lock_commit().unwrap();
12053 let leader_runtime = Arc::clone(&runtime);
12054 let leader = std::thread::spawn(move || {
12055 leader_runtime.execute_sql_batch(vec![command("overload-first", 1)])
12056 });
12057 runtime
12058 .sql_group_commit
12059 .wait_for_pending_calls(1, Duration::from_secs(5));
12060 let overload = runtime
12061 .execute_sql_batch(vec![command("overload-second", 2)])
12062 .unwrap_err();
12063 assert!(matches!(overload, NodeError::ResourceExhausted(_)));
12064 drop(commit);
12065 assert!(leader.join().unwrap().unwrap()[0].is_ok());
12066 assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
12067 }
12068
12069 #[test]
12070 fn sql_group_commit_bounds_pending_bytes_and_releases_reservations() {
12071 let queue = super::SqlGroupCommitQueue::new(super::MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY);
12072 let cancelled = AtomicBool::new(false);
12073 let member = |id: usize| super::RuntimeBatchMember {
12074 request_id: format!("queued-{id}"),
12075 payload: vec![u8::try_from(id).unwrap_or_default(); MAX_COMMAND_BYTES],
12076 operation: super::QueuedOperation::Sql(SqlCommand {
12077 request_id: format!("queued-{id}"),
12078 statements: vec![SqlStatement {
12079 sql: "SELECT 1".into(),
12080 parameters: vec![],
12081 }],
12082 }),
12083 };
12084 let mut queued = Vec::new();
12085 for id in 0..super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY {
12086 queued.push(queue.enqueue(vec![member(id)], &cancelled).unwrap().0);
12087 }
12088
12089 let overflow = match queue.enqueue(
12090 vec![member(super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY)],
12091 &cancelled,
12092 ) {
12093 Ok(_) => panic!("pending byte budget must reject one more full command"),
12094 Err(error) => error,
12095 };
12096 assert!(matches!(overflow, NodeError::ResourceExhausted(_)));
12097
12098 let drained = queue.drain_next_group().unwrap();
12099 assert_eq!(drained.len(), 4);
12100 let released = queue
12101 .enqueue(
12102 vec![member(super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY + 1)],
12103 &cancelled,
12104 )
12105 .unwrap()
12106 .0;
12107
12108 queue.fail_pending(NodeError::Unavailable("test failure".into()));
12109 for job in queued.into_iter().skip(drained.len()) {
12110 assert!(matches!(
12111 job.wait(&cancelled),
12112 Err(NodeError::Unavailable(_))
12113 ));
12114 }
12115 assert!(matches!(
12116 released.wait(&cancelled),
12117 Err(NodeError::Unavailable(_))
12118 ));
12119 let state = queue
12120 .state
12121 .lock()
12122 .unwrap_or_else(std::sync::PoisonError::into_inner);
12123 assert!(state.pending.is_empty());
12124 assert_eq!(state.pending_encoded_bytes, 0);
12125 assert!(!state.leader_active);
12126 }
12127
12128 #[test]
12129 fn sql_group_commit_window_restarts_after_staggered_enqueue() {
12130 let queue = Arc::new(super::SqlGroupCommitQueue::new(
12131 super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY,
12132 ));
12133 let cancelled = AtomicBool::new(false);
12134 let member = |id: usize| super::RuntimeBatchMember {
12135 request_id: format!("sql-debounce-{id}"),
12136 payload: vec![u8::try_from(id).unwrap_or_default()],
12137 operation: super::QueuedOperation::Sql(SqlCommand {
12138 request_id: format!("sql-debounce-{id}"),
12139 statements: vec![SqlStatement {
12140 sql: "SELECT 1".into(),
12141 parameters: vec![],
12142 }],
12143 }),
12144 };
12145 queue.enqueue(vec![member(1)], &cancelled).unwrap();
12146 let collector = Arc::clone(&queue);
12147 let (finished, receive) = std::sync::mpsc::channel();
12148 let worker = std::thread::spawn(move || {
12149 let collected = collector.collect_until_full_or_timeout(Duration::from_millis(100));
12150 finished.send(collected).unwrap();
12151 });
12152
12153 std::thread::sleep(Duration::from_millis(75));
12154 queue.enqueue(vec![member(2)], &cancelled).unwrap();
12155 std::thread::sleep(Duration::from_millis(75));
12156 queue.enqueue(vec![member(3)], &cancelled).unwrap();
12157 assert!(receive.recv_timeout(Duration::from_millis(50)).is_err());
12158 assert!(receive.recv_timeout(Duration::from_millis(150)).unwrap());
12159 worker.join().unwrap();
12160 assert_eq!(queue.drain_next_group().unwrap().len(), 3);
12161 }
12162
12163 #[test]
12164 fn sql_group_commit_leader_panic_wakes_every_queued_call_with_the_same_fatal_error() {
12165 let (_dir, mut runtime) = sql_test_runtime();
12166 runtime
12167 .execute_sql(SqlCommand {
12168 request_id: "panic-schema".into(),
12169 statements: vec![SqlStatement {
12170 sql: "CREATE TABLE panic_items(id INTEGER PRIMARY KEY)".into(),
12171 parameters: vec![],
12172 }],
12173 })
12174 .unwrap();
12175 runtime.sql_group_commit_before_execute_hook =
12176 Some(Arc::new(|| panic!("injected SQL group leader panic")));
12177 let runtime = Arc::new(runtime);
12178 let commit = runtime.lock_commit().unwrap();
12179 let mut workers = Vec::new();
12180 for id in 1..=2 {
12181 let worker_runtime = Arc::clone(&runtime);
12182 workers.push(std::thread::spawn(move || {
12183 worker_runtime.execute_sql_batch(vec![SqlCommand {
12184 request_id: format!("panic-{id}"),
12185 statements: vec![SqlStatement {
12186 sql: "INSERT INTO panic_items(id) VALUES (?1)".into(),
12187 parameters: vec![SqlValue::Integer(id)],
12188 }],
12189 }])
12190 }));
12191 runtime
12192 .sql_group_commit
12193 .wait_for_pending_calls(id as usize, Duration::from_secs(5));
12194 }
12195 drop(commit);
12196
12197 let errors = workers
12198 .into_iter()
12199 .map(|worker| worker.join().unwrap().unwrap_err())
12200 .collect::<Vec<_>>();
12201 assert!(errors
12202 .iter()
12203 .all(|error| matches!(error, NodeError::Fatal(_))));
12204 assert_eq!(errors[0].to_string(), errors[1].to_string());
12205 assert!(runtime.is_fatal());
12206 }
12207
12208 #[test]
12209 fn sql_group_commit_shutdown_wakes_queued_calls_with_the_same_unavailable_error() {
12210 let (_dir, runtime) = sql_test_runtime();
12211 let runtime = Arc::new(runtime);
12212 runtime
12213 .execute_sql(SqlCommand {
12214 request_id: "shutdown-schema".into(),
12215 statements: vec![SqlStatement {
12216 sql: "CREATE TABLE shutdown_items(id INTEGER PRIMARY KEY)".into(),
12217 parameters: vec![],
12218 }],
12219 })
12220 .unwrap();
12221 let command = |id| SqlCommand {
12222 request_id: format!("shutdown-{id}"),
12223 statements: vec![SqlStatement {
12224 sql: "INSERT INTO shutdown_items(id) VALUES (?1)".into(),
12225 parameters: vec![SqlValue::Integer(id)],
12226 }],
12227 };
12228
12229 let commit = runtime.lock_commit().unwrap();
12230 let leader_runtime = Arc::clone(&runtime);
12231 let leader = std::thread::spawn(move || leader_runtime.execute_sql_batch(vec![command(1)]));
12232 runtime
12233 .sql_group_commit
12234 .wait_for_pending_calls(1, Duration::from_secs(5));
12235 let follower_runtime = Arc::clone(&runtime);
12236 let follower =
12237 std::thread::spawn(move || follower_runtime.execute_sql_batch(vec![command(2)]));
12238 runtime
12239 .sql_group_commit
12240 .wait_for_pending_calls(2, Duration::from_secs(5));
12241
12242 runtime.cancel_operations();
12243 let follower_error = follower.join().unwrap().unwrap_err();
12244 drop(commit);
12245 let leader_error = leader.join().unwrap().unwrap_err();
12246
12247 assert!(matches!(leader_error, NodeError::Unavailable(_)));
12248 assert_eq!(leader_error.to_string(), follower_error.to_string());
12249 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
12250 }
12251
12252 #[test]
12253 fn sql_group_commit_reprepares_combined_calls_after_a_foreign_slot_winner() {
12254 let (_dir, runtime) = sql_test_runtime();
12255 let runtime = Arc::new(runtime);
12256 let schema = runtime
12257 .execute_sql(SqlCommand {
12258 request_id: "group-winner-schema".into(),
12259 statements: vec![SqlStatement {
12260 sql: "CREATE TABLE group_winner(id INTEGER PRIMARY KEY)".into(),
12261 parameters: vec![],
12262 }],
12263 })
12264 .unwrap();
12265 let winner = runtime
12266 .consensus()
12267 .propose_at(
12268 2,
12269 schema.hash,
12270 Command::new(CommandKind::ReadBarrier, Vec::new()),
12271 )
12272 .unwrap();
12273
12274 let commit = runtime.lock_commit().unwrap();
12275 let mut workers = Vec::new();
12276 for id in 1..=2 {
12277 let worker_runtime = Arc::clone(&runtime);
12278 workers.push(std::thread::spawn(move || {
12279 worker_runtime
12280 .execute_sql_batch(vec![SqlCommand {
12281 request_id: format!("group-winner-{id}"),
12282 statements: vec![SqlStatement {
12283 sql: "INSERT INTO group_winner(id) VALUES (?1)".into(),
12284 parameters: vec![SqlValue::Integer(id)],
12285 }],
12286 }])
12287 .unwrap()
12288 }));
12289 runtime
12290 .sql_group_commit
12291 .wait_for_pending_calls(id as usize, Duration::from_secs(5));
12292 }
12293 drop(commit);
12294
12295 let results = workers
12296 .into_iter()
12297 .flat_map(|worker| worker.join().unwrap())
12298 .collect::<Vec<_>>();
12299 assert_eq!(runtime.log_store().read(2).unwrap(), Some(winner));
12300 assert!(results
12301 .iter()
12302 .all(|result| result.as_ref().unwrap().applied_index == 3));
12303 assert_eq!(
12304 results[0].as_ref().unwrap().hash,
12305 results[1].as_ref().unwrap().hash
12306 );
12307 }
12308
12309 #[test]
12310 fn sql_group_commit_all_failed_calls_return_aligned_without_consensus() {
12311 let (_dir, runtime) = sql_test_runtime();
12312 let runtime = Arc::new(runtime);
12313 runtime
12314 .execute_sql(SqlCommand {
12315 request_id: "all-failed-schema".into(),
12316 statements: vec![SqlStatement {
12317 sql: "CREATE TABLE all_failed(value TEXT UNIQUE)".into(),
12318 parameters: vec![],
12319 }],
12320 })
12321 .unwrap();
12322 runtime
12323 .execute_sql(SqlCommand {
12324 request_id: "all-failed-existing".into(),
12325 statements: vec![SqlStatement {
12326 sql: "INSERT INTO all_failed(value) VALUES ('existing')".into(),
12327 parameters: vec![],
12328 }],
12329 })
12330 .unwrap();
12331
12332 let commit = runtime.lock_commit().unwrap();
12333 let mut workers = Vec::new();
12334 for id in 1..=2 {
12335 let worker_runtime = Arc::clone(&runtime);
12336 workers.push(std::thread::spawn(move || {
12337 worker_runtime
12338 .execute_sql_batch(vec![SqlCommand {
12339 request_id: format!("all-failed-{id}"),
12340 statements: vec![SqlStatement {
12341 sql: "INSERT INTO all_failed(value) VALUES ('existing')".into(),
12342 parameters: vec![],
12343 }],
12344 }])
12345 .unwrap()
12346 }));
12347 runtime
12348 .sql_group_commit
12349 .wait_for_pending_calls(id as usize, Duration::from_secs(5));
12350 }
12351 drop(commit);
12352
12353 for worker in workers {
12354 let results = worker.join().unwrap();
12355 assert_eq!(results.len(), 1);
12356 assert!(matches!(
12357 results[0],
12358 Err(NodeError::InvalidSqlStatement { .. })
12359 ));
12360 }
12361 assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
12362 }
12363
12364 #[test]
12365 fn sql_group_commit_lone_call_completes_after_the_bounded_collection_round() {
12366 let (_dir, runtime) = sql_test_runtime();
12367
12368 let result = runtime
12369 .execute_sql_batch(vec![SqlCommand {
12370 request_id: "lone-call".into(),
12371 statements: vec![SqlStatement {
12372 sql: "CREATE TABLE lone_call(id INTEGER PRIMARY KEY)".into(),
12373 parameters: vec![],
12374 }],
12375 }])
12376 .unwrap();
12377
12378 assert!(result[0].is_ok());
12379 let queue = runtime
12380 .sql_group_commit
12381 .state
12382 .lock()
12383 .unwrap_or_else(std::sync::PoisonError::into_inner);
12384 assert!(queue.pending.is_empty());
12385 assert!(!queue.leader_active);
12386 }
12387
12388 #[test]
12389 fn key_value_write_commits_qwal_instead_of_raw_put_payload() {
12390 let (_dir, runtime) = sql_test_runtime();
12391
12392 let response = runtime.write("key-value-put", "key", "value").unwrap();
12393
12394 let entry = runtime
12395 .log_store()
12396 .read(response.applied_index)
12397 .unwrap()
12398 .unwrap();
12399 assert!(entry.payload.starts_with(QWAL_V3_MAGIC));
12400 assert!(!entry.payload.starts_with(b"put\t"));
12401 assert_eq!(
12402 runtime.read("key", ReadConsistency::Local).unwrap().value,
12403 Some("value".into())
12404 );
12405 }
12406
12407 #[test]
12408 fn sql_batch_preserves_order_commits_one_qwal_effect_and_retries_exactly() {
12409 let (_dir, runtime) = sql_test_runtime();
12410 runtime
12411 .execute_sql(SqlCommand {
12412 request_id: "schema".into(),
12413 statements: vec![SqlStatement {
12414 sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
12415 .into(),
12416 parameters: vec![],
12417 }],
12418 })
12419 .unwrap();
12420 let commands = (1..=3)
12421 .map(|id| SqlCommand {
12422 request_id: format!("insert-{id}"),
12423 statements: vec![SqlStatement {
12424 sql: "INSERT INTO batch_items(id, value) VALUES (?1, ?2)".into(),
12425 parameters: vec![SqlValue::Integer(id), SqlValue::Text(format!("value-{id}"))],
12426 }],
12427 })
12428 .collect::<Vec<_>>();
12429
12430 let first = runtime.execute_sql_batch(commands.clone()).unwrap();
12431 let first_indices = first
12432 .iter()
12433 .map(|result| result.as_ref().unwrap().applied_index)
12434 .collect::<Vec<_>>();
12435 let log_index = runtime.log_store().last_index().unwrap();
12436 let replay = runtime.execute_sql_batch(commands).unwrap();
12437
12438 assert_eq!(first_indices, vec![2, 2, 2]);
12439 assert_eq!(
12440 first
12441 .iter()
12442 .map(|result| result.as_ref().unwrap().hash)
12443 .collect::<std::collections::HashSet<_>>()
12444 .len(),
12445 1
12446 );
12447 for index in 1..=2 {
12448 let entry = runtime.log_store().read(index).unwrap().unwrap();
12449 assert!(entry.payload.starts_with(QWAL_V3_MAGIC));
12450 }
12451 assert_eq!(
12452 replay
12453 .iter()
12454 .map(|result| result.as_ref().unwrap().applied_index)
12455 .collect::<Vec<_>>(),
12456 first_indices
12457 );
12458 assert_eq!(runtime.log_store().last_index().unwrap(), log_index);
12459 }
12460
12461 #[test]
12462 fn sql_effect_over_qlog_limit_is_resource_exhausted() {
12463 let (_dir, runtime) = sql_test_runtime();
12464 runtime
12465 .execute_sql(SqlCommand {
12466 request_id: "schema".into(),
12467 statements: vec![SqlStatement {
12468 sql: "CREATE TABLE large_effect(value BLOB NOT NULL)".into(),
12469 parameters: vec![],
12470 }],
12471 })
12472 .unwrap();
12473
12474 let error = runtime
12475 .execute_sql(SqlCommand {
12476 request_id: "large-effect".into(),
12477 statements: vec![SqlStatement {
12478 sql: "INSERT INTO large_effect(value) VALUES (randomblob(700000))".into(),
12479 parameters: vec![],
12480 }],
12481 })
12482 .unwrap_err();
12483
12484 assert!(matches!(error, NodeError::ResourceExhausted(_)));
12485 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
12486 }
12487
12488 #[test]
12489 fn sql_batch_isolates_request_conflict_from_unrelated_member() {
12490 let (_dir, runtime) = sql_test_runtime();
12491 runtime
12492 .execute_sql(SqlCommand {
12493 request_id: "schema".into(),
12494 statements: vec![SqlStatement {
12495 sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
12496 .into(),
12497 parameters: vec![],
12498 }],
12499 })
12500 .unwrap();
12501 let insert = |request_id: &str, id: i64| SqlCommand {
12502 request_id: request_id.into(),
12503 statements: vec![SqlStatement {
12504 sql: "INSERT INTO batch_items(id, value) VALUES (?1, ?2)".into(),
12505 parameters: vec![SqlValue::Integer(id), SqlValue::Text(format!("value-{id}"))],
12506 }],
12507 };
12508
12509 let results = runtime
12510 .execute_sql_batch(vec![
12511 insert("same", 1),
12512 insert("same", 2),
12513 insert("other", 3),
12514 ])
12515 .unwrap();
12516
12517 assert!(results[0].is_ok());
12518 assert!(matches!(results[1], Err(NodeError::RequestConflict(_))));
12519 let conflict = results[1].as_ref().unwrap_err().classification();
12520 assert_eq!(conflict.code(), "request_conflict");
12521 assert_eq!(conflict.category(), ErrorCategory::Conflict);
12522 assert!(!conflict.retryable());
12523 assert!(results[2].is_ok());
12524 assert_eq!(
12525 results[0].as_ref().unwrap().applied_index,
12526 results[2].as_ref().unwrap().applied_index
12527 );
12528 assert!(runtime.is_ready());
12529 }
12530
12531 #[cfg(feature = "graph")]
12532 #[test]
12533 fn typed_batch_wrong_profile_is_rejected_before_log_attempt() {
12534 let (_dir, runtime) = graph_test_runtime();
12535 let command = SqlCommand {
12536 request_id: "wrong-profile".into(),
12537 statements: vec![SqlStatement {
12538 sql: "CREATE TABLE should_not_exist(id INTEGER PRIMARY KEY)".into(),
12539 parameters: vec![],
12540 }],
12541 };
12542
12543 let error = runtime.execute_sql_batch(vec![command]).unwrap_err();
12544
12545 assert!(matches!(
12546 error,
12547 NodeError::ExecutionProfileMismatch {
12548 expected: ExecutionProfile::Sqlite,
12549 actual: ExecutionProfile::Graph
12550 }
12551 ));
12552 assert_eq!(runtime.log_store().last_index().unwrap(), None);
12553 }
12554
12555 #[cfg(feature = "graph")]
12556 #[test]
12557 fn graph_query_timeout_returns_503_without_latching_readiness() {
12558 let (_dir, runtime) = graph_test_runtime();
12559 let graph = runtime.graph_materializer().unwrap();
12560 let graph_error = graph
12561 .query_read_only(
12562 "UNWIND range(1, 10000) AS x UNWIND range(1, 10000) AS y RETURN sum(x * y) AS total LIMIT 1",
12563 &std::collections::BTreeMap::new(),
12564 1,
12565 1024 * 1024,
12566 1,
12567 )
12568 .unwrap_err();
12569
12570 let response = node_error_response(runtime.map_graph_read_error(graph_error));
12571
12572 assert_eq!(
12573 response.status(),
12574 axum::http::StatusCode::SERVICE_UNAVAILABLE
12575 );
12576 assert!(runtime.is_ready());
12577 assert!(!runtime.is_fatal());
12578 }
12579
12580 #[cfg(feature = "graph")]
12581 #[test]
12582 fn graph_internal_error_returns_500_and_latches_readiness() {
12583 let (_dir, runtime) = graph_test_runtime();
12584
12585 let error =
12586 runtime.map_graph_read_error(rhiza_graph::Error::Ladybug("connection failed".into()));
12587 let response = node_error_response(error);
12588
12589 assert_eq!(
12590 response.status(),
12591 axum::http::StatusCode::INTERNAL_SERVER_ERROR
12592 );
12593 assert!(!runtime.is_ready());
12594 assert!(runtime.is_fatal());
12595 }
12596
12597 fn sql_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12598 sql_test_runtime_configured(None, None)
12599 }
12600
12601 fn sql_test_runtime_with_profiler(
12602 profiler: Option<SqlWriteProfiler>,
12603 ) -> (tempfile::TempDir, NodeRuntime) {
12604 sql_test_runtime_configured(profiler, None)
12605 }
12606
12607 fn sql_test_runtime_configured(
12608 profiler: Option<SqlWriteProfiler>,
12609 queue_capacity: Option<usize>,
12610 ) -> (tempfile::TempDir, NodeRuntime) {
12611 let dir = tempfile::tempdir().unwrap();
12612 let cluster_id = "node-unit-test";
12613 let mut config = NodeConfig::new_embedded(
12614 cluster_id,
12615 "n1",
12616 dir.path().join("node"),
12617 1,
12618 1,
12619 ["n1", "n2", "n3"],
12620 )
12621 .unwrap()
12622 .with_execution_profile(ExecutionProfile::Sqlite)
12623 .unwrap();
12624 if let Some(profiler) = profiler {
12625 config = config.with_sql_write_profiler(profiler);
12626 }
12627 if let Some(queue_capacity) = queue_capacity {
12628 config = config
12629 .with_sql_group_commit_queue_capacity(queue_capacity)
12630 .unwrap();
12631 }
12632 let consensus = Arc::new(
12633 ThreeNodeConsensus::from_recovered_tip(
12634 "rhiza:sql:node-unit-test",
12635 "n1",
12636 1,
12637 1,
12638 [
12639 dir.path().join("recorders/n1"),
12640 dir.path().join("recorders/n2"),
12641 dir.path().join("recorders/n3"),
12642 ],
12643 1,
12644 LogHash::ZERO,
12645 )
12646 .unwrap(),
12647 );
12648 let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12649 (dir, runtime)
12650 }
12651
12652 #[cfg(feature = "graph")]
12653 fn graph_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12654 let dir = tempfile::tempdir().unwrap();
12655 let cluster_id = "node-unit-test";
12656 let config = NodeConfig::new_embedded(
12657 cluster_id,
12658 "n1",
12659 dir.path().join("node"),
12660 1,
12661 1,
12662 ["n1", "n2", "n3"],
12663 )
12664 .unwrap()
12665 .with_execution_profile(ExecutionProfile::Graph)
12666 .unwrap();
12667 let consensus = Arc::new(
12668 ThreeNodeConsensus::from_recovered_tip(
12669 "rhiza:graph:node-unit-test",
12670 "n1",
12671 1,
12672 1,
12673 [
12674 dir.path().join("recorders/n1"),
12675 dir.path().join("recorders/n2"),
12676 dir.path().join("recorders/n3"),
12677 ],
12678 1,
12679 LogHash::ZERO,
12680 )
12681 .unwrap(),
12682 );
12683 let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12684 (dir, runtime)
12685 }
12686
12687 #[cfg(feature = "kv")]
12688 fn kv_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12689 let dir = tempfile::tempdir().unwrap();
12690 let cluster_id = "node-unit-test";
12691 let config = NodeConfig::new_embedded(
12692 cluster_id,
12693 "n1",
12694 dir.path().join("node"),
12695 1,
12696 1,
12697 ["n1", "n2", "n3"],
12698 )
12699 .unwrap()
12700 .with_execution_profile(ExecutionProfile::Kv)
12701 .unwrap();
12702 let consensus = Arc::new(
12703 ThreeNodeConsensus::from_recovered_tip(
12704 "rhiza:kv:node-unit-test",
12705 "n1",
12706 1,
12707 1,
12708 [
12709 dir.path().join("recorders/n1"),
12710 dir.path().join("recorders/n2"),
12711 dir.path().join("recorders/n3"),
12712 ],
12713 1,
12714 LogHash::ZERO,
12715 )
12716 .unwrap(),
12717 );
12718 let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12719 (dir, runtime)
12720 }
12721}
12722
12723#[cfg(feature = "kv")]
12724fn largest_fitting_kv_batch_prefix(commands: &[KvCommandV1]) -> Option<(usize, Vec<u8>)> {
12725 if commands.len() < 3 {
12726 return None;
12727 }
12728 let mut lower = 2_usize;
12729 let mut upper = commands.len() - 1;
12730 let mut largest = None;
12731 while lower <= upper {
12732 let count = lower + (upper - lower) / 2;
12733 let payload = encode_replicated_kv_batch(&commands[..count])
12734 .expect("the validated KV batch prefix remains valid");
12735 if payload.len() <= MAX_COMMAND_BYTES {
12736 largest = Some((count, payload));
12737 lower = count + 1;
12738 } else {
12739 upper = count - 1;
12740 }
12741 }
12742 largest
12743}
12744
12745#[cfg(feature = "graph")]
12746fn validate_typed_batch_len(len: usize) -> Result<(), NodeError> {
12747 if (1..=MAX_WRITE_BATCH_MEMBERS).contains(&len) {
12748 Ok(())
12749 } else {
12750 Err(NodeError::InvalidRequest(format!(
12751 "write batch must contain 1..={MAX_WRITE_BATCH_MEMBERS} commands"
12752 )))
12753 }
12754}
12755
12756#[cfg(feature = "kv")]
12757fn validate_kv_batch_len(len: usize) -> Result<(), NodeError> {
12758 if (1..=MAX_KV_BATCH_MEMBERS).contains(&len) {
12759 Ok(())
12760 } else {
12761 Err(NodeError::InvalidRequest(format!(
12762 "KV write batch must contain 1..={MAX_KV_BATCH_MEMBERS} commands"
12763 )))
12764 }
12765}
12766
12767#[cfg(feature = "sql")]
12768fn validate_sql_batch_len(len: usize) -> Result<(), NodeError> {
12769 if (1..=MAX_TYPED_SQL_WRITE_BATCH_MEMBERS).contains(&len) {
12770 Ok(())
12771 } else {
12772 Err(NodeError::InvalidRequest(format!(
12773 "SQL write batch must contain 1..={MAX_TYPED_SQL_WRITE_BATCH_MEMBERS} commands"
12774 )))
12775 }
12776}
12777
12778fn validate_command_size(payload: &[u8]) -> Result<(), NodeError> {
12779 if payload.len() <= MAX_COMMAND_BYTES {
12780 Ok(())
12781 } else {
12782 Err(NodeError::InvalidRequest(format!(
12783 "command exceeds {MAX_COMMAND_BYTES} bytes"
12784 )))
12785 }
12786}
12787
12788#[cfg(feature = "sql")]
12789fn canonical_put(request_id: &str, key: &str, value: &str) -> Result<Vec<u8>, NodeError> {
12790 validate_field("request_id", request_id, MAX_REQUEST_ID_BYTES, false)?;
12791 validate_key(key)?;
12792 validate_field("value", value, MAX_VALUE_BYTES, true)?;
12793 let payload = encode_put_request(request_id, key, value)
12794 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
12795 validate_command_size(&payload)?;
12796 Ok(payload)
12797}
12798
12799#[cfg(feature = "sql")]
12800fn encode_sql_command_with_index(command: &SqlCommand) -> Result<Vec<u8>, NodeError> {
12801 encode_sql_command(command).map_err(|error| {
12802 let message = error.to_string();
12803 match first_invalid_sql_statement(command, |prefix| encode_sql_command(prefix).is_err()) {
12804 Some(statement_index) => NodeError::InvalidSqlStatement {
12805 statement_index,
12806 message,
12807 },
12808 None => NodeError::InvalidRequest(message),
12809 }
12810 })
12811}
12812
12813#[cfg(feature = "sql")]
12814fn first_invalid_sql_statement(
12815 command: &SqlCommand,
12816 mut invalid: impl FnMut(&SqlCommand) -> bool,
12817) -> Option<usize> {
12818 if command.statements.is_empty() || command.statements.len() > MAX_SQL_STATEMENTS {
12819 return None;
12820 }
12821 (0..command.statements.len()).find(|statement_index| {
12822 let prefix = SqlCommand {
12823 request_id: command.request_id.clone(),
12824 statements: command.statements[..=*statement_index].to_vec(),
12825 };
12826 invalid(&prefix)
12827 })
12828}
12829
12830#[cfg(feature = "sql")]
12831fn validate_key(key: &str) -> Result<(), NodeError> {
12832 validate_field("key", key, MAX_KEY_BYTES, false)
12833}
12834
12835#[cfg(feature = "sql")]
12836fn validate_field(
12837 name: &str,
12838 value: &str,
12839 max_bytes: usize,
12840 allow_empty: bool,
12841) -> Result<(), NodeError> {
12842 if !allow_empty && value.is_empty() {
12843 return Err(NodeError::InvalidRequest(format!(
12844 "{name} must not be empty"
12845 )));
12846 }
12847 if value.len() > max_bytes {
12848 return Err(NodeError::InvalidRequest(format!(
12849 "{name} exceeds {max_bytes} bytes"
12850 )));
12851 }
12852 if value.contains('\t') {
12853 return Err(NodeError::InvalidRequest(format!(
12854 "{name} must not contain a tab"
12855 )));
12856 }
12857 Ok(())
12858}
12859
12860#[cfg(feature = "sql")]
12861fn write_response(outcome: RequestOutcome) -> WriteResponse {
12862 WriteResponse {
12863 applied_index: outcome.original_log_index(),
12864 hash: outcome.original_log_hash(),
12865 }
12866}
12867
12868fn reconcile_local_storage(
12869 config: &NodeConfig,
12870 log_store: &FileLogStore,
12871 materializer: &Materializer,
12872) -> Result<(), NodeError> {
12873 let mut log_state = log_store
12874 .logical_state()
12875 .map_err(|error| NodeError::Storage(error.to_string()))?;
12876 let mut log_last_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
12877 let applied_index = materializer
12878 .applied_index()
12879 .map_err(|error| NodeError::Storage(error.to_string()))?;
12880 let applied_hash = materializer
12881 .applied_hash()
12882 .map_err(|error| NodeError::Storage(error.to_string()))?;
12883 let mut materializer_configuration = materializer
12884 .configuration_state()
12885 .map_err(|error| NodeError::Storage(error.to_string()))?;
12886
12887 if let Some(anchor) = &log_state.anchor {
12888 if anchor.recovery_generation() != config.recovery_generation {
12889 return Err(NodeError::Reconciliation(format!(
12890 "qlog anchor recovery generation {} differs from runtime generation {}",
12891 anchor.recovery_generation(),
12892 config.recovery_generation
12893 )));
12894 }
12895 if applied_index < anchor.compacted().index() {
12896 return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())));
12897 }
12898 }
12899 if applied_index > log_last_index {
12900 let entries: Option<Vec<LogEntry>> = match materializer {
12901 #[cfg(feature = "sql")]
12902 Materializer::Sql(sql) => Some(
12903 sql.embedded_log_entries(log_last_index.saturating_add(1), applied_index)
12904 .map_err(|error| NodeError::Reconciliation(error.to_string()))?,
12905 ),
12906 #[cfg(feature = "kv")]
12907 Materializer::Kv(kv) => Some(
12908 kv.embedded_log_entries(log_last_index.saturating_add(1), applied_index)
12909 .map_err(|error| NodeError::Reconciliation(error.to_string()))?,
12910 ),
12911 #[cfg(feature = "graph")]
12912 Materializer::Graph(_) => None,
12913 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
12914 Materializer::Unavailable => None,
12915 };
12916 if let Some(entries) = entries {
12917 log_store
12918 .append_batch(&entries)
12919 .map_err(|error| NodeError::Storage(error.to_string()))?;
12920 log_state = log_store
12921 .logical_state()
12922 .map_err(|error| NodeError::Storage(error.to_string()))?;
12923 log_last_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
12924 }
12925 }
12926 if applied_index > log_last_index {
12927 return Err(NodeError::Reconciliation(format!(
12928 "{} materializer is ahead at {applied_index}, qlog ends at {log_last_index}",
12929 materializer.profile()
12930 )));
12931 }
12932 if applied_index == 0 {
12933 if applied_hash != LogHash::ZERO {
12934 return Err(NodeError::Reconciliation(format!(
12935 "{} materializer genesis hash is not zero",
12936 materializer.profile()
12937 )));
12938 }
12939 } else if !log_state.anchor.as_ref().is_some_and(|anchor| {
12940 applied_index == anchor.compacted().index() && applied_hash == anchor.compacted().hash()
12941 }) {
12942 let entry = log_store
12943 .read(applied_index)
12944 .map_err(|error| NodeError::Storage(error.to_string()))?
12945 .ok_or_else(|| {
12946 NodeError::Reconciliation(format!(
12947 "qlog prefix is missing {} materializer index {applied_index}",
12948 materializer.profile()
12949 ))
12950 })?;
12951 validate_entry_envelope(config, &entry, applied_index, entry.prev_hash)?;
12952 if entry.hash != applied_hash {
12953 return Err(NodeError::Reconciliation(format!(
12954 "{} materializer hash diverges from qlog at index {applied_index}",
12955 materializer.profile()
12956 )));
12957 }
12958 }
12959
12960 let mut expected_prev_hash = applied_hash;
12961 for index in (applied_index + 1)..=log_last_index {
12962 let entry = log_store
12963 .read(index)
12964 .map_err(|error| NodeError::Storage(error.to_string()))?
12965 .ok_or_else(|| {
12966 NodeError::Reconciliation(format!("qlog prefix is missing index {index}"))
12967 })?;
12968 match &materializer_configuration {
12969 Some(configuration) => {
12970 validate_runtime_entry(config, configuration, &entry, index, expected_prev_hash)?
12971 }
12972 None => validate_entry_envelope(config, &entry, index, expected_prev_hash)?,
12973 };
12974 materializer
12975 .apply_entry(&entry)
12976 .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12977 materializer_configuration = materializer
12978 .configuration_state()
12979 .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12980 expected_prev_hash = entry.hash;
12981 }
12982 let log_configuration = log_store
12983 .configuration_state()
12984 .map_err(|error| NodeError::Storage(error.to_string()))?;
12985 if materializer_configuration
12986 .as_ref()
12987 .is_some_and(|configuration| configuration != &log_configuration)
12988 {
12989 return Err(NodeError::Reconciliation(format!(
12990 "qlog and {} materializer configuration states disagree",
12991 materializer.profile()
12992 )));
12993 }
12994 Ok(())
12995}
12996
12997fn recover_peer_candidates(
12998 config: &NodeConfig,
12999 consensus: &ThreeNodeConsensus,
13000 log_store: &FileLogStore,
13001 materializer: &Materializer,
13002 peer_candidates: &[&dyn LogPeer],
13003 startup: &StartupIoContext,
13004) -> Result<(), NodeError> {
13005 for peer in peer_candidates {
13006 let (last_index, last_hash) = static_log_tip(log_store)?;
13007 startup.check("peer log fetch")?;
13008 let candidates = match peer.fetch_log(FetchLogRequest {
13009 from_index: last_index.saturating_add(1),
13010 max_entries: MAX_FETCH_ENTRIES,
13011 }) {
13012 Ok(response) => validate_fetched_entries_with_configuration(
13013 last_index.saturating_add(1),
13014 last_hash,
13015 &config.cluster_id,
13016 config.epoch,
13017 log_store
13018 .configuration_state()
13019 .map_err(|error| NodeError::Storage(error.to_string()))?,
13020 response.entries,
13021 )
13022 .map_err(|error| {
13023 NodeError::Reconciliation(format!("peer candidate validation failed: {error}"))
13024 })?,
13025 Err(FetchLogError::Transport { .. }) => continue,
13026 Err(FetchLogError::SnapshotRequired { anchor }) => {
13027 return Err(NodeError::SnapshotRequired(anchor));
13028 }
13029 Err(error) => {
13030 return Err(NodeError::Reconciliation(format!(
13031 "peer candidate validation failed: {error}"
13032 )));
13033 }
13034 };
13035 startup.check("peer log fetch")?;
13036
13037 let mut expected_index = last_index.checked_add(1).ok_or_else(|| {
13038 NodeError::Reconciliation("qlog index is exhausted during peer catch-up".into())
13039 })?;
13040 let mut expected_prev_hash = last_hash;
13041 for candidate in candidates {
13042 startup.check("peer decision inspection")?;
13043 match consensus
13044 .inspect_decision_at(expected_index, expected_prev_hash)
13045 .map_err(startup_consensus_error)?
13046 {
13047 DecisionInspection::Committed(committed) if committed == candidate => {
13048 startup.check("peer decision inspection")?;
13049 persist_startup_entry(
13050 config,
13051 log_store,
13052 materializer,
13053 &candidate,
13054 expected_index,
13055 expected_prev_hash,
13056 startup,
13057 )?;
13058 expected_prev_hash = candidate.hash;
13059 expected_index = expected_index.checked_add(1).ok_or_else(|| {
13060 NodeError::Reconciliation(
13061 "qlog index is exhausted during peer catch-up".into(),
13062 )
13063 })?;
13064 }
13065 DecisionInspection::Committed(_) => {
13066 return Err(NodeError::Reconciliation(format!(
13067 "peer candidate at index {expected_index} differs from committed decision"
13068 )));
13069 }
13070 DecisionInspection::Unavailable => {
13071 return Err(NodeError::Unavailable(format!(
13072 "decision inspection unavailable for peer candidate at index {expected_index}"
13073 )));
13074 }
13075 DecisionInspection::Empty | DecisionInspection::Pending => {
13076 return Err(NodeError::Reconciliation(format!(
13077 "peer candidate at index {expected_index} is not committed"
13078 )));
13079 }
13080 }
13081 }
13082 }
13083 Ok(())
13084}
13085
13086fn recover_startup_decisions(
13087 config: &NodeConfig,
13088 consensus: &ThreeNodeConsensus,
13089 log_store: &FileLogStore,
13090 materializer: &Materializer,
13091 startup: &StartupIoContext,
13092) -> Result<(), NodeError> {
13093 for _ in 0..MAX_STARTUP_RECOVERY_ENTRIES {
13094 let (last_index, last_hash) = static_log_tip(log_store)?;
13095 let slot = last_index.checked_add(1).ok_or_else(|| {
13096 NodeError::Reconciliation("qlog index is exhausted during startup".into())
13097 })?;
13098 startup.check("recorder decision inspection")?;
13099 match consensus
13100 .inspect_decision_at(slot, last_hash)
13101 .map_err(startup_consensus_error)?
13102 {
13103 DecisionInspection::Committed(entry) => {
13104 startup.check("recorder decision inspection")?;
13105 persist_startup_entry(
13106 config,
13107 log_store,
13108 materializer,
13109 &entry,
13110 slot,
13111 last_hash,
13112 startup,
13113 )?;
13114 }
13115 DecisionInspection::Pending => {
13116 let entry = consensus
13117 .propose_at_cancellable(
13118 slot,
13119 last_hash,
13120 Command::new(CommandKind::ReadBarrier, Vec::new()),
13121 startup.cancellation_flag(),
13122 )
13123 .map_err(startup_consensus_error)?;
13124 startup.check("recorder pending decision recovery")?;
13125 persist_startup_entry(
13126 config,
13127 log_store,
13128 materializer,
13129 &entry,
13130 slot,
13131 last_hash,
13132 startup,
13133 )?;
13134 }
13135 DecisionInspection::Empty => return Ok(()),
13136 DecisionInspection::Unavailable => {
13137 return Err(NodeError::Unavailable(
13138 "decision inspection unavailable during startup".into(),
13139 ));
13140 }
13141 }
13142 }
13143 Err(NodeError::Reconciliation(format!(
13144 "startup recovery exceeded {MAX_STARTUP_RECOVERY_ENTRIES} entries"
13145 )))
13146}
13147
13148fn persist_startup_entry(
13149 config: &NodeConfig,
13150 log_store: &FileLogStore,
13151 materializer: &Materializer,
13152 entry: &LogEntry,
13153 expected_index: LogIndex,
13154 expected_prev_hash: LogHash,
13155 startup: &StartupIoContext,
13156) -> Result<(), NodeError> {
13157 startup.persist("startup qlog and materializer persistence", || {
13158 let configuration_state = log_store
13159 .configuration_state()
13160 .map_err(|error| NodeError::Storage(error.to_string()))?;
13161 validate_runtime_entry(
13162 config,
13163 &configuration_state,
13164 entry,
13165 expected_index,
13166 expected_prev_hash,
13167 )?;
13168 log_store
13169 .append(entry)
13170 .map_err(|error| NodeError::Storage(error.to_string()))?;
13171 materializer
13172 .apply_entry(entry)
13173 .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
13174 Ok(())
13175 })
13176}
13177
13178fn static_log_tip(log_store: &FileLogStore) -> Result<(LogIndex, LogHash), NodeError> {
13179 Ok(log_store
13180 .logical_state()
13181 .map_err(|error| NodeError::Storage(error.to_string()))?
13182 .tip
13183 .map_or((0, LogHash::ZERO), |tip| (tip.index(), tip.hash())))
13184}
13185
13186fn validate_runtime_entry(
13187 config: &NodeConfig,
13188 configuration_state: &ConfigurationState,
13189 entry: &LogEntry,
13190 expected_index: LogIndex,
13191 expected_prev_hash: LogHash,
13192) -> Result<(), NodeError> {
13193 validate_entry_envelope(config, entry, expected_index, expected_prev_hash)?;
13194 validate_profile_entry_shape(config.execution_profile(), entry)
13195 .map_err(NodeError::Invariant)?;
13196 configuration_state
13197 .validate_entry(entry)
13198 .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
13199 Ok(())
13200}
13201
13202fn validate_entry_envelope(
13203 config: &NodeConfig,
13204 entry: &LogEntry,
13205 expected_index: LogIndex,
13206 expected_prev_hash: LogHash,
13207) -> Result<(), NodeError> {
13208 if entry.index != expected_index {
13209 return Err(NodeError::Reconciliation(format!(
13210 "expected decision index {expected_index}, got {}",
13211 entry.index
13212 )));
13213 }
13214 if entry.cluster_id != config.cluster_id || entry.epoch != config.epoch {
13215 return Err(NodeError::Reconciliation(format!(
13216 "decision {} has a foreign identity",
13217 entry.index
13218 )));
13219 }
13220 if entry.prev_hash != expected_prev_hash {
13221 return Err(NodeError::Reconciliation(format!(
13222 "decision {} has a conflicting predecessor",
13223 entry.index
13224 )));
13225 }
13226 if entry.recompute_hash() != entry.hash {
13227 return Err(NodeError::Reconciliation(format!(
13228 "decision {} has an invalid hash",
13229 entry.index
13230 )));
13231 }
13232 validate_entry_shape(entry).map_err(NodeError::Invariant)
13233}
13234
13235fn validate_entry_shape(entry: &LogEntry) -> Result<(), String> {
13236 match entry.entry_type {
13237 EntryType::Command if entry.payload.len() <= MAX_COMMAND_BYTES => Ok(()),
13238 EntryType::Command => Err(format!("command exceeds {MAX_COMMAND_BYTES} bytes")),
13239 EntryType::Noop if entry.payload.is_empty() => Ok(()),
13240 EntryType::Noop => Err("Noop payload must be empty".into()),
13241 EntryType::ConfigChange => ConfigChange::recognize(&StoredCommand::new(
13242 EntryType::ConfigChange,
13243 entry.payload.clone(),
13244 ))
13245 .map(|_| ())
13246 .map_err(|error| error.to_string()),
13247 other => Err(format!("unsupported runtime entry type {other:?}")),
13248 }
13249}
13250
13251pub(crate) fn validate_profile_entry_shape(
13252 _profile: ExecutionProfile,
13253 entry: &LogEntry,
13254) -> Result<(), String> {
13255 validate_entry_shape(entry)?;
13256 #[cfg(feature = "sql")]
13257 if _profile == ExecutionProfile::Sqlite && entry.entry_type == EntryType::Command {
13258 decode_qwal_v3(&entry.payload)
13259 .map_err(|error| format!("SQLite command is not canonical QWAL v3: {error}"))?;
13260 }
13261 Ok(())
13262}
13263
13264fn startup_consensus_error(error: rhiza_quepaxa::Error) -> NodeError {
13265 match error {
13266 rhiza_quepaxa::Error::NoQuorum
13267 | rhiza_quepaxa::Error::CommandUnavailable
13268 | rhiza_quepaxa::Error::Cancelled
13269 | rhiza_quepaxa::Error::Io(_) => NodeError::Unavailable(error.to_string()),
13270 other => NodeError::Reconciliation(other.to_string()),
13271 }
13272}
13273
13274#[cfg(feature = "sql")]
13275#[derive(Clone, Debug, Eq, PartialEq)]
13276pub struct E2eConfig {
13277 pub data_dir: PathBuf,
13278 pub object_store: ObjStoreConfig,
13279 pub cluster_id: String,
13280 pub node_id: String,
13281}
13282
13283#[cfg(feature = "sql")]
13284#[derive(Clone, Debug, Eq, PartialEq)]
13285pub struct E2eReport {
13286 pub applied_index: LogIndex,
13287 pub restored_value: String,
13288 pub object_keys: Vec<String>,
13289}
13290
13291#[cfg(feature = "sql")]
13292pub async fn run_e2e(config: E2eConfig) -> Result<E2eReport, Box<dyn std::error::Error>> {
13293 let sqlite_dir = config.data_dir.join("sqlite");
13294 let log_dir = config.data_dir.join("consensus").join("log");
13295 ensure_fresh_e2e_data_dir(&config.data_dir, &sqlite_dir, &log_dir)?;
13296
13297 fs::create_dir_all(&config.data_dir)?;
13298 let db_path = sqlite_dir.join("db.sqlite");
13299 let restore_path = config.data_dir.join("restore").join("db.sqlite");
13300 let db = SqliteStateMachine::open(&db_path, &config.cluster_id, &config.node_id, 1, 1)?;
13301 let recorder_dir = config.data_dir.join("consensus").join("recorder");
13302 let consensus = ThreeNodeConsensus::new(
13303 &config.cluster_id,
13304 &config.node_id,
13305 1,
13306 1,
13307 [
13308 recorder_dir.join("node-1"),
13309 recorder_dir.join("node-2"),
13310 recorder_dir.join("node-3"),
13311 ],
13312 )?;
13313 let base_request = canonical_put("e2e-base", "alpha", "bravo")?;
13314 let base_effect = db.prepare_put_effect(
13315 "e2e-base",
13316 "alpha",
13317 "bravo",
13318 &base_request,
13319 0,
13320 LogHash::ZERO,
13321 )?;
13322 let base_entry = consensus.propose(Command::new(CommandKind::Deterministic, base_effect))?;
13323 db.apply_entry(&base_entry)?;
13324 let snapshot = db.create_snapshot(base_entry.index)?;
13325
13326 let tail_request = canonical_put("e2e-tail", "alpha", "charlie")?;
13327 let tail_effect = db.prepare_put_effect(
13328 "e2e-tail",
13329 "alpha",
13330 "charlie",
13331 &tail_request,
13332 base_entry.index,
13333 base_entry.hash,
13334 )?;
13335 let tail_entry = consensus.propose(Command::new(CommandKind::Deterministic, tail_effect))?;
13336 let segment_path = write_segment_file(&log_dir, std::slice::from_ref(&tail_entry))?;
13337 let segment = rhiza_log::SegmentFile::new(
13338 IndexRange::new(tail_entry.index, tail_entry.index)?,
13339 fs::read(&segment_path)?,
13340 );
13341 db.apply_entry(&tail_entry)?;
13342
13343 let local_archive = matches!(&config.object_store, ObjStoreConfig::Local { .. });
13344 let store = ObjStore::new(config.object_store)?;
13345 let archive = if local_archive {
13346 rhiza_archive::ObjectArchiveStore::new_for_single_process(
13347 store.clone(),
13348 config.cluster_id.clone(),
13349 )
13350 } else {
13351 rhiza_archive::ObjectArchiveStore::new(store.clone(), config.cluster_id.clone())?
13352 };
13353
13354 let segment_record = archive.publish_segment(tail_entry.epoch, &segment).await?;
13355 let snapshot_record = archive.publish_snapshot(&snapshot).await?;
13356 let (mut archive_manifest, expected_manifest_version) = match archive.load_manifest().await? {
13357 Some(loaded) => (loaded.manifest().clone(), Some(loaded.version().clone())),
13358 None => (
13359 rhiza_archive::ArchiveManifest::new(config.cluster_id.clone()),
13360 None,
13361 ),
13362 };
13363 archive_manifest.set_latest_snapshot(snapshot_record);
13364 archive_manifest.add_segment(segment_record);
13365 archive
13366 .publish_manifest(&archive_manifest, expected_manifest_version)
13367 .await?;
13368
13369 let loaded_manifest = archive
13370 .load_manifest()
13371 .await?
13372 .ok_or("published archive manifest is missing")?;
13373 if loaded_manifest.manifest() != &archive_manifest {
13374 return Err("reloaded archive manifest did not match the published manifest".into());
13375 }
13376 let archived_snapshot = loaded_manifest
13377 .manifest()
13378 .latest_snapshot()
13379 .ok_or("archive manifest is missing its snapshot")?;
13380 let archived_segment = loaded_manifest
13381 .manifest()
13382 .segments()
13383 .iter()
13384 .find(|record| {
13385 record.start_index() == tail_entry.index && record.end_index() == tail_entry.index
13386 })
13387 .ok_or("archive manifest is missing its post-snapshot segment")?;
13388
13389 let downloaded_segment = archive.download_segment(archived_segment).await?;
13390 let downloaded_entries = decode_segment_for_cluster(&downloaded_segment, &config.cluster_id)?;
13391 if downloaded_entries.as_slice() != std::slice::from_ref(&tail_entry) {
13392 return Err("downloaded qlog segment did not match written entry".into());
13393 }
13394 let downloaded_snapshot = rhiza_core::Snapshot::new(
13395 archived_snapshot.manifest().clone(),
13396 archive.download_snapshot(archived_snapshot).await?,
13397 );
13398 restore_snapshot_file(&restore_path, &downloaded_snapshot, &config.node_id)?;
13399 let restored_db = SqliteStateMachine::open_existing(&restore_path)?;
13400 if restored_db.get_value("alpha")?.as_deref() != Some("bravo") {
13401 return Err("restored base snapshot is missing alpha=bravo".into());
13402 }
13403 for entry in &downloaded_entries {
13404 restored_db.apply_entry(entry)?;
13405 }
13406 let restored_value = restored_db
13407 .get_value("alpha")?
13408 .ok_or("restored SQLite state is missing alpha")?;
13409 let applied_index = restored_db.applied_index_value()?;
13410 if applied_index != tail_entry.index || restored_value != "charlie" {
13411 return Err("restored SQLite state did not include the archived log tail".into());
13412 }
13413 let object_keys = store.list(&format!("rhiza/{}", config.cluster_id)).await?;
13414
13415 Ok(E2eReport {
13416 applied_index,
13417 restored_value,
13418 object_keys,
13419 })
13420}
13421
13422#[cfg(feature = "sql")]
13423fn ensure_fresh_e2e_data_dir(
13424 data_dir: &std::path::Path,
13425 sqlite_dir: &std::path::Path,
13426 log_dir: &std::path::Path,
13427) -> Result<(), Box<dyn std::error::Error>> {
13428 if directory_has_entries(sqlite_dir)? || directory_has_entries(log_dir)? {
13429 return Err(format!(
13430 "e2e data directory is not fresh: prior SQLite/qlog data exists in {}",
13431 data_dir.display()
13432 )
13433 .into());
13434 }
13435 Ok(())
13436}
13437
13438#[cfg(feature = "sql")]
13439fn directory_has_entries(path: &std::path::Path) -> Result<bool, std::io::Error> {
13440 match fs::read_dir(path) {
13441 Ok(mut entries) => entries.next().transpose().map(|entry| entry.is_some()),
13442 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
13443 Err(error) => Err(error),
13444 }
13445}