1use std::{
2 collections::{HashMap, HashSet},
3 fmt, fs,
4 path::{Path, PathBuf},
5 sync::{
6 atomic::{AtomicBool, Ordering},
7 Arc, Condvar, Mutex, MutexGuard,
8 },
9 time::{Duration, Instant},
10};
11
12#[cfg(feature = "sql")]
13use std::collections::VecDeque;
14
15#[cfg(not(feature = "sql"))]
16compile_error!("rhiza-node requires the sql execution profile feature");
17
18#[cfg(feature = "graph")]
19use std::collections::BTreeMap;
20#[cfg(any(feature = "sql", test))]
21use std::sync::atomic::AtomicUsize;
22
23use axum::{
24 extract::{rejection::JsonRejection, DefaultBodyLimit, Extension, Request, State},
25 http::{HeaderMap, StatusCode},
26 middleware::{self, Next},
27 response::{IntoResponse, Response},
28 routing::{get, post},
29 Json, Router,
30};
31#[cfg(feature = "sql")]
32use rhiza_archive::SnapshotRecord;
33use rhiza_core::{
34 Command, CommandKind, ConfigChange, ConfigurationState, EntryType, ErrorClassification,
35 ExecutionProfile, LogAnchor, LogEntry, LogHash, LogIndex, RecoveryAnchor, StoredCommand,
36};
37#[cfg(feature = "graph")]
38use rhiza_graph::{
39 encode_replicated_graph_batch, encode_replicated_graph_command, CanonicalF64, GraphColumn,
40 GraphInternalId, GraphLogicalType, GraphNode, GraphParameterValue, GraphQueryResult, GraphRel,
41 GraphResultValue, LadybugStateMachine, RequestRecord as GraphRequestRecord,
42};
43#[cfg(feature = "kv")]
44use rhiza_kv::{
45 encode_replicated_kv_batch, encode_replicated_kv_command, KvRequestRecord, RedbStateMachine,
46 MAX_KV_BATCH_MEMBERS,
47};
48#[cfg(feature = "kv")]
49pub use rhiza_kv::{KvScanResult, KvScanRow, MAX_KV_SCAN_RESULT_BYTES, MAX_KV_SCAN_ROWS};
50#[cfg(feature = "sql")]
51use rhiza_log::{decode_segment_for_cluster, write_segment_file};
52use rhiza_log::{FileLogStore, IndexRange, LogStore};
53#[cfg(feature = "sql")]
54use rhiza_obj_store::{ObjStore, ObjStoreConfig};
55#[cfg(feature = "sql")]
56use rhiza_quepaxa::Consensus;
57use rhiza_quepaxa::{
58 CertifiedDecisionInspection, DecisionInspection, DecisionProof, Membership,
59 ReadFenceObservation, ReadFenceRequest, RecordRequest, RecordSummary, RecorderFileStore,
60 RecorderRpc, RejectReason, ThreeNodeConsensus,
61};
62#[cfg(feature = "sql")]
63use rhiza_sql::{
64 decode_qwal_v3, encode_put_request, encode_sql_command, restore_snapshot_file,
65 RecoverySnapshot, RequestConflict, RequestOutcome, SqlBatchMember, SqlCommand,
66 SqlCommandResult, SqlQueryResult, SqlStatement, SqlValue, SqliteStateMachine,
67 MAX_QWAL_V3_RECEIPTS, MAX_SQL_STATEMENTS, QWAL_V3_MAGIC,
68};
69#[cfg(not(feature = "sql"))]
70type SqlCommandResult = ();
71
72mod admin;
73pub mod durability;
74#[cfg(feature = "graph")]
75mod graph;
76#[cfg(feature = "kv")]
77mod kv;
78mod recorder_tcp;
79pub use admin::*;
80pub use durability::{
81 restore_checkpoint_to_fresh_data_dir, restore_checkpoint_to_fresh_data_dir_for_node,
82 restore_successor_checkpoint_to_fresh_data_dir, CheckpointCoordinator, DurabilityError,
83 DurabilityHealth, DurabilityMode, SuccessorRestorePreparation,
84};
85#[cfg(feature = "graph")]
86pub use graph::*;
87#[cfg(feature = "kv")]
88pub use kv::*;
89#[cfg(feature = "recorder-postcard-rpc")]
90pub use recorder_tcp::{
91 serve_recorder_postcard_rpc, serve_recorder_postcard_rpc_tls,
92 RecorderPostcardRpcTlsClientConfig, RecorderPostcardRpcTlsServerConfig,
93 TcpPostcardRpcRecorderClient,
94};
95pub use recorder_tcp::{
96 serve_recorder_tcp, serve_recorder_tcp_tls, validate_recorder_tcp_endpoint,
97 RecorderTlsClientConfig, RecorderTlsServerConfig, TcpPostcardRecorderClient,
98};
99
100pub const MAX_FETCH_ENTRIES: u32 = 1_024;
101pub const MAX_COMMAND_BYTES: usize = 512 * 1024;
102pub const MAX_REQUEST_ID_BYTES: usize = 256;
103pub const MAX_KEY_BYTES: usize = 4 * 1024;
104pub const MAX_VALUE_BYTES: usize = 240 * 1024;
105pub const MAX_HTTP_BODY_BYTES: usize = MAX_COMMAND_BYTES * 6 + 16 * 1024;
106pub const DEFAULT_CLIENT_CONCURRENCY: usize = 16;
107pub const DEFAULT_PEER_CONCURRENCY: usize = 32;
108pub const DEFAULT_WRITER_BATCH_MAX: usize = 8;
109const MAX_WRITE_BATCH_MEMBERS: usize = 64;
110#[cfg(feature = "sql")]
111const MAX_SQL_WRITE_BATCH_MEMBERS: usize = MAX_QWAL_V3_RECEIPTS;
112#[cfg(feature = "sql")]
113pub const MAX_TYPED_SQL_WRITE_BATCH_MEMBERS: usize = 256;
114#[cfg(feature = "sql")]
115pub const DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY: usize = 64;
116#[cfg(feature = "sql")]
117pub const MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY: usize = 4_096;
118#[cfg(feature = "sql")]
120const MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES: usize = 4 * MAX_COMMAND_BYTES;
121#[cfg(feature = "sql")]
123const MAX_SQL_GROUP_COMMIT_PENDING_BYTES: usize =
124 DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY * MAX_COMMAND_BYTES;
125#[cfg(feature = "kv")]
126const MAX_KV_GROUP_COMMIT_MEMBERS: usize = 1_024;
127#[cfg(feature = "kv")]
128const KV_GROUP_COMMIT_QUEUE_CAPACITY: usize = 64;
129#[cfg(feature = "kv")]
130const MAX_KV_GROUP_COMMIT_PENDING_BYTES: usize = KV_GROUP_COMMIT_QUEUE_CAPACITY * MAX_COMMAND_BYTES;
131pub const DEFAULT_WRITER_BATCH_WINDOW: Duration = Duration::from_micros(500);
132pub const PROTOCOL_VERSION: &str = "1";
133pub const RECORDER_PROTOCOL_VERSION: &str = "3";
134const RECORDER_WIRE_VERSION: u16 = 3;
135pub const VERSION_HEADER: &str = "x-rhiza-version";
136pub const NODE_ID_HEADER: &str = "x-rhiza-node-id";
137pub const RECOVERY_GENERATION_HEADER: &str = "x-rhiza-recovery-generation";
138pub const RECORDER_IDENTITY_PATH: &str = "/v2/quepaxa/recorder/identity";
139
140#[cfg(feature = "sql")]
145#[derive(Clone)]
146pub struct SqlWriteProfiler {
147 inner: Arc<SqlWriteProfilerInner>,
148}
149
150#[cfg(feature = "sql")]
151struct SqlWriteProfilerInner {
152 capacity: usize,
153 state: Mutex<SqlWriteProfilerState>,
154}
155
156#[cfg(feature = "sql")]
157#[derive(Default)]
158struct SqlWriteProfilerState {
159 samples: VecDeque<SqlWriteProfileSample>,
160 dropped_samples: u64,
161}
162
163#[cfg(feature = "sql")]
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct SqlWriteProfileSample {
167 pub batch_member_count: usize,
168 pub commit_lock_wait_us: u64,
169 pub precheck_classification_us: u64,
170 pub qwal_prepare_us: u64,
171 pub consensus_propose_us: u64,
172 pub local_qlog_mirror_append_us: u64,
173 pub sql_materializer_apply_us: u64,
174 pub response_other_total_us: u64,
175 pub total_service_us: u64,
176}
177
178#[cfg(feature = "sql")]
180#[derive(Clone, Debug, Default, Eq, PartialEq)]
181pub struct SqlWriteProfileSnapshot {
182 pub samples: Vec<SqlWriteProfileSample>,
183 pub dropped_samples: u64,
184}
185
186#[cfg(feature = "sql")]
187impl SqlWriteProfiler {
188 pub fn new(capacity: usize) -> Self {
194 assert!(capacity > 0, "SQL write profiler capacity must be non-zero");
195 Self {
196 inner: Arc::new(SqlWriteProfilerInner {
197 capacity,
198 state: Mutex::new(SqlWriteProfilerState::default()),
199 }),
200 }
201 }
202
203 pub fn snapshot(&self) -> SqlWriteProfileSnapshot {
204 let state = self
205 .inner
206 .state
207 .lock()
208 .unwrap_or_else(std::sync::PoisonError::into_inner);
209 SqlWriteProfileSnapshot {
210 samples: state.samples.iter().cloned().collect(),
211 dropped_samples: state.dropped_samples,
212 }
213 }
214
215 pub fn drain(&self) -> SqlWriteProfileSnapshot {
217 let mut state = self
218 .inner
219 .state
220 .lock()
221 .unwrap_or_else(std::sync::PoisonError::into_inner);
222 SqlWriteProfileSnapshot {
223 samples: state.samples.drain(..).collect(),
224 dropped_samples: state.dropped_samples,
225 }
226 }
227
228 fn record(&self, sample: SqlWriteProfileSample) {
229 let mut state = self
230 .inner
231 .state
232 .lock()
233 .unwrap_or_else(std::sync::PoisonError::into_inner);
234 if state.samples.len() == self.inner.capacity {
235 state.samples.pop_front();
236 state.dropped_samples = state.dropped_samples.saturating_add(1);
237 }
238 state.samples.push_back(sample);
239 }
240}
241
242#[cfg(feature = "sql")]
243impl fmt::Debug for SqlWriteProfiler {
244 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245 f.debug_struct("SqlWriteProfiler")
246 .field("capacity", &self.inner.capacity)
247 .finish_non_exhaustive()
248 }
249}
250
251#[cfg(feature = "sql")]
252impl PartialEq for SqlWriteProfiler {
253 fn eq(&self, other: &Self) -> bool {
254 Arc::ptr_eq(&self.inner, &other.inner)
255 }
256}
257
258#[cfg(feature = "sql")]
259impl Eq for SqlWriteProfiler {}
260pub const RECORDER_STORE_COMMAND_PATH: &str = "/v2/quepaxa/recorder/store-command";
261pub const RECORDER_FETCH_COMMAND_PATH: &str = "/v2/quepaxa/recorder/fetch-command";
262pub const RECORDER_INSPECT_PROOF_PATH: &str = "/v2/quepaxa/recorder/inspect-proof";
263pub const RECORDER_INSPECT_RECORD_PATH: &str = "/v2/quepaxa/recorder/inspect-record";
264pub const RECORDER_READ_FENCE_PATH: &str = "/v3/quepaxa/recorder/read-fence";
265pub const RECORDER_RECORD_PATH: &str = "/v2/quepaxa/recorder/record";
266pub const RECORDER_INSTALL_PROOF_PATH: &str = "/v2/quepaxa/recorder/install-decision-proof";
267pub const LOG_FETCH_PATH: &str = "/v1/log/fetch";
268#[cfg(feature = "sql")]
269pub const WRITE_PATH: &str = "/v1/write";
270#[cfg(feature = "sql")]
271pub const READ_PATH: &str = "/v1/read";
272#[cfg(feature = "sql")]
273pub const SQL_EXECUTE_PATH: &str = "/v1/sql/execute";
274#[cfg(feature = "sql")]
275pub const SQL_QUERY_PATH: &str = "/v1/sql/query";
276#[cfg(feature = "graph")]
277pub const GRAPH_PUT_DOCUMENT_PATH: &str = "/v1/graph/documents/put";
278#[cfg(feature = "graph")]
279pub const GRAPH_DELETE_DOCUMENT_PATH: &str = "/v1/graph/documents/delete";
280#[cfg(feature = "graph")]
281pub const GRAPH_GET_DOCUMENT_PATH: &str = "/v1/graph/documents/get";
282#[cfg(feature = "graph")]
283pub const GRAPH_QUERY_PATH: &str = "/v1/graph/query";
284#[cfg(feature = "kv")]
285pub const KV_PUT_PATH: &str = "/v1/kv/put";
286#[cfg(feature = "kv")]
287pub const KV_DELETE_PATH: &str = "/v1/kv/delete";
288#[cfg(feature = "kv")]
289pub const KV_GET_PATH: &str = "/v1/kv/get";
290#[cfg(feature = "kv")]
291pub const KV_SCAN_PATH: &str = "/v1/kv/scan";
292#[cfg(feature = "sql")]
293pub const SQL_EXECUTE_RESPONSE_VERSION: u16 = 1;
294pub const LIVEZ_PATH: &str = "/livez";
295pub const READYZ_PATH: &str = "/readyz";
296const MAX_STARTUP_RECOVERY_ENTRIES: usize = 100_000;
297const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
298const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
299const READ_FENCE_REQUEST_TIMEOUT: Duration = Duration::from_secs(1);
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 EmptyClientToken,
400 ClientTokenConflictsWithPeer,
401 EmptyAdminToken,
402 AdminTokenConflictsWithRuntime,
403 HttpClient(String),
404}
405
406impl fmt::Display for ConfigError {
407 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
408 match self {
409 Self::EmptyClusterId => write!(f, "cluster_id must not be empty"),
410 Self::ClusterIdProfileMismatch { expected, actual } => write!(
411 f,
412 "cluster_id is canonical for the {} profile, not {}",
413 actual.as_str(),
414 expected.as_str()
415 ),
416 Self::EmptyNodeId => write!(f, "node_id must not be empty"),
417 Self::EmptyDataDir => write!(f, "data_dir must not be empty"),
418 Self::InvalidEpoch => write!(f, "epoch must be positive"),
419 Self::InvalidConfigId => write!(f, "config_id must be positive"),
420 Self::InvalidRecoveryGeneration => {
421 write!(f, "recovery_generation must be positive")
422 }
423 Self::InvalidWriterBatchMax(max) => write!(
424 f,
425 "writer batch max must be within 1..={MAX_WRITE_BATCH_MEMBERS}, got {max}"
426 ),
427 Self::InvalidWriterBatchWindow => write!(
428 f,
429 "writer batch window must be positive and shorter than the client deadline"
430 ),
431 #[cfg(feature = "sql")]
432 Self::InvalidSqlGroupCommitQueueCapacity(capacity) => write!(
433 f,
434 "SQL group commit queue capacity must be within 1..={MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY}, got {capacity}"
435 ),
436 Self::EmptyPeerNodeId => write!(f, "peer node_id must not be empty"),
437 Self::EmptyPeerBaseUrl => write!(f, "peer base_url must not be empty"),
438 Self::InvalidPeerBaseUrl(url) => write!(f, "invalid peer base_url: {url}"),
439 Self::EmptyPeerToken => write!(f, "peer token must not be empty"),
440 Self::DuplicatePeerToken => write!(f, "peer tokens must be unique"),
441 Self::InvalidPeerCount(count) => {
442 write!(
443 f,
444 "peer membership requires between three and seven nodes, got {count}"
445 )
446 }
447 Self::DuplicatePeerNodeId(node_id) => {
448 write!(f, "peer node_id must be unique: {node_id}")
449 }
450 Self::LocalNodeMissing => write!(f, "peer set must include the local node_id"),
451 Self::PeerMembershipMismatch => {
452 write!(
453 f,
454 "peer identities must exactly match the canonical membership"
455 )
456 }
457 Self::EmptyClientToken => write!(f, "client token must not be empty"),
458 Self::ClientTokenConflictsWithPeer => {
459 write!(f, "client token must differ from every peer token")
460 }
461 Self::EmptyAdminToken => write!(f, "admin token must not be empty"),
462 Self::AdminTokenConflictsWithRuntime => {
463 write!(f, "admin token must differ from client and peer tokens")
464 }
465 Self::HttpClient(message) => write!(f, "HTTP client configuration failed: {message}"),
466 }
467 }
468}
469
470impl std::error::Error for ConfigError {}
471
472fn validate_recovery_generation(recovery_generation: u64) -> Result<(), ConfigError> {
473 if recovery_generation == 0 {
474 Err(ConfigError::InvalidRecoveryGeneration)
475 } else {
476 Ok(())
477 }
478}
479
480#[derive(Clone, Copy, Debug, Eq, PartialEq)]
481pub enum AckMode {
482 HaFirst,
483 DrStrong,
484}
485
486#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
487#[serde(rename_all = "snake_case")]
488pub enum ReadConsistency {
489 Local,
490 ReadBarrier,
491 AppliedIndex(LogIndex),
492}
493
494#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
495pub struct FetchLogRequest {
496 pub from_index: LogIndex,
497 pub max_entries: u32,
498}
499
500#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
501pub struct FetchLogResponse {
502 pub entries: Vec<LogEntry>,
503 pub last_index: LogIndex,
504}
505
506#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
507pub enum FetchLogError {
508 SnapshotRequired {
509 anchor: Box<RecoveryAnchor>,
510 },
511 Gap {
512 expected: LogIndex,
513 actual: Option<LogIndex>,
514 },
515 Decode {
516 message: String,
517 },
518 Transport {
519 message: String,
520 },
521 InvalidAnchor {
522 expected: LogHash,
523 actual: LogHash,
524 },
525 InvalidEntry {
526 index: LogIndex,
527 message: String,
528 },
529 ForeignIdentity {
530 index: LogIndex,
531 },
532 InvalidRequest {
533 message: String,
534 },
535}
536
537impl fmt::Display for FetchLogError {
538 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
539 match self {
540 Self::SnapshotRequired { anchor } => {
541 write!(
542 f,
543 "snapshot restore required at qlog anchor {}",
544 anchor.compacted().index()
545 )
546 }
547 Self::Gap { expected, actual } => {
548 write!(f, "qlog gap: expected {expected}, got {actual:?}")
549 }
550 Self::Decode { message } => write!(f, "qlog response decode failed: {message}"),
551 Self::Transport { message } => write!(f, "qlog transport failed: {message}"),
552 Self::InvalidAnchor { .. } => write!(f, "qlog response has an invalid anchor"),
553 Self::InvalidEntry { index, message } => {
554 write!(f, "qlog entry {index} is invalid: {message}")
555 }
556 Self::ForeignIdentity { index } => {
557 write!(f, "qlog entry {index} has a foreign identity")
558 }
559 Self::InvalidRequest { message } => write!(f, "invalid qlog request: {message}"),
560 }
561 }
562}
563
564impl std::error::Error for FetchLogError {}
565
566pub trait LogPeer: Send + Sync {
567 fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError>;
568}
569
570#[derive(Clone, Debug, Eq, PartialEq)]
571pub struct InMemoryLogPeer {
572 entries: Vec<LogEntry>,
573 anchor: Option<RecoveryAnchor>,
574}
575
576impl InMemoryLogPeer {
577 pub fn new(mut entries: Vec<LogEntry>) -> Self {
578 entries.sort_by_key(|entry| entry.index);
579 Self {
580 entries,
581 anchor: None,
582 }
583 }
584
585 pub fn with_anchor(mut entries: Vec<LogEntry>, anchor: RecoveryAnchor) -> Self {
586 entries.sort_by_key(|entry| entry.index);
587 Self {
588 entries,
589 anchor: Some(anchor),
590 }
591 }
592}
593
594impl LogPeer for InMemoryLogPeer {
595 fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
596 if let Some(anchor) = &self.anchor {
597 if request.from_index <= anchor.compacted().index() {
598 return Err(FetchLogError::SnapshotRequired {
599 anchor: Box::new(anchor.clone()),
600 });
601 }
602 }
603 let entries = self
604 .entries
605 .iter()
606 .filter(|entry| entry.index >= request.from_index)
607 .take(request.max_entries as usize)
608 .cloned()
609 .collect();
610 let last_index = self
611 .entries
612 .last()
613 .map(|entry| entry.index)
614 .or_else(|| {
615 self.anchor
616 .as_ref()
617 .map(|anchor| anchor.compacted().index())
618 })
619 .unwrap_or(0);
620 Ok(FetchLogResponse {
621 entries,
622 last_index,
623 })
624 }
625}
626
627#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
628struct RecorderWire<T> {
629 version: u16,
630 body: T,
631}
632
633#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
634#[serde(tag = "status", content = "body")]
635enum RecorderV2Result<T> {
636 Ok(T),
637 Rejected(RejectReason),
638 Error(String),
639}
640
641#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
642struct StoreCommandV2 {
643 cluster_id: String,
644 epoch: u64,
645 config_id: u64,
646 config_digest: LogHash,
647 command_hash: LogHash,
648 command: StoredCommand,
649}
650
651#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
652struct FetchCommandV2 {
653 cluster_id: String,
654 epoch: u64,
655 config_id: u64,
656 config_digest: LogHash,
657 command_hash: LogHash,
658}
659
660#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
661struct InspectProofV2 {
662 slot: u64,
663}
664
665#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
666struct InstallProofV2 {
667 proof: DecisionProof,
668 members: Vec<String>,
669}
670
671#[derive(Clone, Debug)]
672pub struct HttpRecorderClient {
673 base_url: String,
674 local_node_id: String,
675 peer_token: String,
676 recovery_generation: u64,
677 client: Arc<std::sync::OnceLock<reqwest::blocking::Client>>,
678}
679
680impl HttpRecorderClient {
681 pub fn new(
682 base_url: impl Into<String>,
683 local_node_id: impl Into<String>,
684 peer_token: impl Into<String>,
685 ) -> Result<Self, ConfigError> {
686 Self::new_with_recovery_generation(base_url, local_node_id, peer_token, 1)
687 }
688
689 pub fn new_with_recovery_generation(
690 base_url: impl Into<String>,
691 local_node_id: impl Into<String>,
692 peer_token: impl Into<String>,
693 recovery_generation: u64,
694 ) -> Result<Self, ConfigError> {
695 validate_recovery_generation(recovery_generation)?;
696 let peer = PeerConfig::new(local_node_id, base_url, peer_token)?;
697 Ok(Self {
698 base_url: peer.base_url,
699 local_node_id: peer.node_id,
700 peer_token: peer.token,
701 recovery_generation,
702 client: Arc::new(std::sync::OnceLock::new()),
703 })
704 }
705
706 pub fn with_recovery_generation(
707 mut self,
708 recovery_generation: u64,
709 ) -> Result<Self, ConfigError> {
710 validate_recovery_generation(recovery_generation)?;
711 self.recovery_generation = recovery_generation;
712 Ok(self)
713 }
714
715 fn url(&self, path: &str) -> String {
716 format!("{}{}", self.base_url, path)
717 }
718
719 fn client(&self) -> rhiza_quepaxa::Result<&reqwest::blocking::Client> {
720 if self.client.get().is_none() {
721 let client = reqwest::blocking::Client::builder()
722 .connect_timeout(HTTP_CONNECT_TIMEOUT)
723 .timeout(HTTP_REQUEST_TIMEOUT)
724 .build()
725 .map_err(|error| rhiza_quepaxa::Error::Io(error.to_string()))?;
726 let _ = self.client.set(client);
727 }
728 self.client
729 .get()
730 .ok_or_else(|| rhiza_quepaxa::Error::Io("HTTP client initialization failed".into()))
731 }
732
733 fn post_v2<T, U>(&self, path: &str, body: T) -> rhiza_quepaxa::Result<U>
734 where
735 T: serde::Serialize,
736 U: serde::de::DeserializeOwned,
737 {
738 self.post_v2_with_timeout(path, body, HTTP_REQUEST_TIMEOUT)
739 }
740
741 fn post_v2_with_timeout<T, U>(
742 &self,
743 path: &str,
744 body: T,
745 timeout: Duration,
746 ) -> rhiza_quepaxa::Result<U>
747 where
748 T: serde::Serialize,
749 U: serde::de::DeserializeOwned,
750 {
751 let response = self
752 .client()?
753 .post(self.url(path))
754 .timeout(timeout)
755 .header(VERSION_HEADER, RECORDER_PROTOCOL_VERSION)
756 .header(NODE_ID_HEADER, &self.local_node_id)
757 .header(
758 RECOVERY_GENERATION_HEADER,
759 self.recovery_generation.to_string(),
760 )
761 .bearer_auth(&self.peer_token)
762 .json(&RecorderWire {
763 version: RECORDER_WIRE_VERSION,
764 body,
765 })
766 .send()
767 .map_err(|error| rhiza_quepaxa::Error::Io(error.to_string()))?;
768 let status = response.status();
769 let wire = response
770 .json::<RecorderWire<RecorderV2Result<U>>>()
771 .map_err(|error| rhiza_quepaxa::Error::Decode(error.to_string()))?;
772 if wire.version != RECORDER_WIRE_VERSION {
773 return Err(rhiza_quepaxa::Error::Decode(
774 "recorder wire version mismatch".into(),
775 ));
776 }
777 match wire.body {
778 RecorderV2Result::Ok(value) if status.is_success() => Ok(value),
779 RecorderV2Result::Ok(_) => Err(rhiza_quepaxa::Error::Io(format!(
780 "recorder rpc returned HTTP {status}"
781 ))),
782 RecorderV2Result::Rejected(reason) => Err(rhiza_quepaxa::Error::Rejected(reason)),
783 RecorderV2Result::Error(message) => Err(rhiza_quepaxa::Error::Io(message)),
784 }
785 }
786}
787
788impl RecorderRpc for HttpRecorderClient {
789 fn recorder_id(&self) -> rhiza_quepaxa::Result<String> {
790 self.post_v2(RECORDER_IDENTITY_PATH, ())
791 }
792
793 fn store_command_for(
794 &self,
795 cluster_id: String,
796 epoch: u64,
797 config_id: u64,
798 config_digest: LogHash,
799 command_hash: LogHash,
800 command: StoredCommand,
801 ) -> rhiza_quepaxa::Result<()> {
802 self.post_v2(
803 RECORDER_STORE_COMMAND_PATH,
804 StoreCommandV2 {
805 cluster_id,
806 epoch,
807 config_id,
808 config_digest,
809 command_hash,
810 command,
811 },
812 )
813 }
814
815 fn fetch_command_for(
816 &self,
817 cluster_id: String,
818 epoch: u64,
819 config_id: u64,
820 config_digest: LogHash,
821 command_hash: LogHash,
822 ) -> rhiza_quepaxa::Result<Option<StoredCommand>> {
823 self.post_v2(
824 RECORDER_FETCH_COMMAND_PATH,
825 FetchCommandV2 {
826 cluster_id,
827 epoch,
828 config_id,
829 config_digest,
830 command_hash,
831 },
832 )
833 }
834
835 fn record(&self, request: RecordRequest) -> rhiza_quepaxa::Result<RecordSummary> {
836 self.post_v2_with_timeout(RECORDER_RECORD_PATH, request, QUORUM_RECORD_REQUEST_TIMEOUT)
837 .map_err(map_quorum_record_transport_error)
838 }
839
840 fn install_decision_proof(
841 &self,
842 proof: DecisionProof,
843 membership: &Membership,
844 ) -> rhiza_quepaxa::Result<()> {
845 self.post_v2(
846 RECORDER_INSTALL_PROOF_PATH,
847 InstallProofV2 {
848 proof,
849 members: membership.members().to_vec(),
850 },
851 )
852 }
853
854 fn inspect_decision_proof(&self, slot: u64) -> rhiza_quepaxa::Result<Option<DecisionProof>> {
855 self.post_v2(RECORDER_INSPECT_PROOF_PATH, InspectProofV2 { slot })
856 }
857
858 fn inspect_record_summary(&self, slot: u64) -> rhiza_quepaxa::Result<Option<RecordSummary>> {
859 self.post_v2(RECORDER_INSPECT_RECORD_PATH, InspectProofV2 { slot })
860 }
861
862 fn supports_context_read_fence(&self) -> bool {
863 true
864 }
865
866 fn observe_read_fence(
867 &self,
868 request: ReadFenceRequest,
869 ) -> rhiza_quepaxa::Result<ReadFenceObservation> {
870 self.post_v2_with_timeout(
871 RECORDER_READ_FENCE_PATH,
872 request,
873 READ_FENCE_REQUEST_TIMEOUT,
874 )
875 }
876}
877
878#[derive(Clone, Debug)]
879pub struct HttpLogPeer {
880 base_url: String,
881 local_node_id: String,
882 peer_token: String,
883 recovery_generation: u64,
884 client: Arc<std::sync::OnceLock<reqwest::blocking::Client>>,
885}
886
887impl HttpLogPeer {
888 pub fn new(
889 base_url: impl Into<String>,
890 local_node_id: impl Into<String>,
891 peer_token: impl Into<String>,
892 ) -> Result<Self, ConfigError> {
893 Self::new_with_recovery_generation(base_url, local_node_id, peer_token, 1)
894 }
895
896 pub fn new_with_recovery_generation(
897 base_url: impl Into<String>,
898 local_node_id: impl Into<String>,
899 peer_token: impl Into<String>,
900 recovery_generation: u64,
901 ) -> Result<Self, ConfigError> {
902 validate_recovery_generation(recovery_generation)?;
903 let peer = PeerConfig::new(local_node_id, base_url, peer_token)?;
904 Ok(Self {
905 base_url: peer.base_url,
906 local_node_id: peer.node_id,
907 peer_token: peer.token,
908 recovery_generation,
909 client: Arc::new(std::sync::OnceLock::new()),
910 })
911 }
912
913 pub fn with_recovery_generation(
914 mut self,
915 recovery_generation: u64,
916 ) -> Result<Self, ConfigError> {
917 validate_recovery_generation(recovery_generation)?;
918 self.recovery_generation = recovery_generation;
919 Ok(self)
920 }
921
922 fn url(&self, path: &str) -> String {
923 format!("{}{}", self.base_url, path)
924 }
925
926 fn client(&self) -> Result<&reqwest::blocking::Client, FetchLogError> {
927 if self.client.get().is_none() {
928 let client = reqwest::blocking::Client::builder()
929 .connect_timeout(HTTP_CONNECT_TIMEOUT)
930 .timeout(HTTP_REQUEST_TIMEOUT)
931 .build()
932 .map_err(|error| FetchLogError::Transport {
933 message: error.to_string(),
934 })?;
935 let _ = self.client.set(client);
936 }
937 self.client.get().ok_or_else(|| FetchLogError::Transport {
938 message: "HTTP client initialization failed".into(),
939 })
940 }
941}
942
943impl LogPeer for HttpLogPeer {
944 fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
945 let response = self
946 .client()?
947 .post(self.url(LOG_FETCH_PATH))
948 .header(VERSION_HEADER, PROTOCOL_VERSION)
949 .header(NODE_ID_HEADER, &self.local_node_id)
950 .header(
951 RECOVERY_GENERATION_HEADER,
952 self.recovery_generation.to_string(),
953 )
954 .bearer_auth(&self.peer_token)
955 .json(&request)
956 .send()
957 .map_err(|err| FetchLogError::Transport {
958 message: err.to_string(),
959 })?;
960 let status = response.status();
961 match response
962 .json::<FetchLogHttpResponse>()
963 .map_err(|err| FetchLogError::Decode {
964 message: err.to_string(),
965 })? {
966 FetchLogHttpResponse::Fetched(response) if status.is_success() => Ok(response),
967 FetchLogHttpResponse::Fetched(_) => Err(FetchLogError::Transport {
968 message: format!("log rpc returned HTTP {status}"),
969 }),
970 FetchLogHttpResponse::Failed(error) => Err(error),
971 }
972 }
973}
974
975#[derive(Clone)]
976struct RecorderRouteState<R> {
977 recorder: R,
978 peers: Vec<PeerConfig>,
979}
980
981#[derive(Clone)]
982struct AuthenticatedPeer(String);
983
984#[derive(Clone)]
985struct LogRouteState<P> {
986 peer: P,
987}
988
989#[derive(Clone)]
990struct NodeRouteState {
991 runtime: Arc<NodeRuntime>,
992 coordinator: Option<Arc<CheckpointCoordinator>>,
993 write_operations: Arc<tokio::sync::Mutex<HashMap<String, WriteOperation>>>,
994 writer: tokio::sync::mpsc::Sender<QueuedWrite>,
995}
996
997#[derive(Clone)]
998struct WriteOperation {
999 payload: Vec<u8>,
1000 result: tokio::sync::watch::Receiver<Option<WriteOperationResult>>,
1001}
1002
1003#[derive(Clone)]
1004enum WriteOperationResult {
1005 Runtime(Result<ClientWriteResponse, NodeError>),
1006 DurabilityUnavailable,
1007}
1008
1009#[derive(Clone)]
1010enum ClientWriteResponse {
1011 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1012 Unavailable,
1013 #[cfg(feature = "sql")]
1014 KeyValue(WriteResponse),
1015 #[cfg(feature = "sql")]
1016 Sql(SqlExecuteResponse),
1017 #[cfg(feature = "graph")]
1018 Graph(GraphMutationOutcome),
1019 #[cfg(feature = "kv")]
1020 Kv(KvMutationOutcome),
1021}
1022
1023struct QueuedWrite {
1024 request_id: String,
1025 payload: Vec<u8>,
1026 operation: QueuedOperation,
1027 permit: Arc<tokio::sync::OwnedSemaphorePermit>,
1028 sender: tokio::sync::watch::Sender<Option<WriteOperationResult>>,
1029}
1030
1031enum QueuedOperation {
1032 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1033 Unavailable,
1034 #[cfg(feature = "sql")]
1035 KeyValue { key: String, value: String },
1036 #[cfg(feature = "sql")]
1037 Sql(SqlCommand),
1038 #[cfg(feature = "graph")]
1039 Graph(GraphCommandV1),
1040 #[cfg(feature = "kv")]
1041 Kv(KvCommandV1),
1042}
1043
1044struct RuntimeBatchMember {
1045 #[cfg(feature = "sql")]
1046 request_id: String,
1047 payload: Vec<u8>,
1048 operation: QueuedOperation,
1049}
1050
1051#[cfg(feature = "sql")]
1052type SqlGroupCommitResult = Result<Vec<Result<ClientWriteResponse, NodeError>>, NodeError>;
1053
1054#[cfg(feature = "sql")]
1055struct SqlGroupCommitJob {
1056 member_count: usize,
1057 encoded_bytes: usize,
1058 members: Mutex<Option<Vec<RuntimeBatchMember>>>,
1059 result: Mutex<Option<SqlGroupCommitResult>>,
1060 changed: Condvar,
1061}
1062
1063#[cfg(feature = "sql")]
1064impl SqlGroupCommitJob {
1065 fn new(members: Vec<RuntimeBatchMember>) -> Self {
1066 Self {
1067 member_count: members.len(),
1068 encoded_bytes: members.iter().fold(0_usize, |bytes, member| {
1069 bytes.saturating_add(member.payload.len())
1070 }),
1071 members: Mutex::new(Some(members)),
1072 result: Mutex::new(None),
1073 changed: Condvar::new(),
1074 }
1075 }
1076
1077 fn take_members(&self) -> Result<Vec<RuntimeBatchMember>, NodeError> {
1078 self.members
1079 .lock()
1080 .unwrap_or_else(std::sync::PoisonError::into_inner)
1081 .take()
1082 .ok_or_else(|| {
1083 NodeError::Invariant("SQL group commit job members were already consumed".into())
1084 })
1085 }
1086
1087 fn publish(&self, result: SqlGroupCommitResult) {
1088 let mut slot = self
1089 .result
1090 .lock()
1091 .unwrap_or_else(std::sync::PoisonError::into_inner);
1092 if slot.is_none() {
1093 *slot = Some(result);
1094 self.changed.notify_all();
1095 }
1096 }
1097
1098 fn wait(&self, cancelled: &AtomicBool) -> SqlGroupCommitResult {
1099 let mut result = self
1100 .result
1101 .lock()
1102 .unwrap_or_else(std::sync::PoisonError::into_inner);
1103 loop {
1104 if let Some(result) = result.take() {
1105 return result;
1106 }
1107 if cancelled.load(Ordering::Acquire) {
1108 return Err(NodeError::Unavailable(
1109 "SQL group commit cancelled during shutdown".into(),
1110 ));
1111 }
1112 result = self
1113 .changed
1114 .wait(result)
1115 .unwrap_or_else(std::sync::PoisonError::into_inner);
1116 }
1117 }
1118}
1119
1120#[cfg(feature = "sql")]
1121struct SqlGroupCommitQueue {
1122 capacity: usize,
1123 state: Mutex<SqlGroupCommitQueueState>,
1124 changed: Condvar,
1125}
1126
1127#[cfg(feature = "sql")]
1128#[derive(Default)]
1129struct SqlGroupCommitQueueState {
1130 pending: VecDeque<Arc<SqlGroupCommitJob>>,
1131 pending_encoded_bytes: usize,
1132 leader_active: bool,
1133}
1134
1135#[cfg(feature = "sql")]
1136impl SqlGroupCommitQueue {
1137 fn new(capacity: usize) -> Self {
1138 Self {
1139 capacity,
1140 state: Mutex::new(SqlGroupCommitQueueState::default()),
1141 changed: Condvar::new(),
1142 }
1143 }
1144
1145 fn enqueue(
1146 &self,
1147 members: Vec<RuntimeBatchMember>,
1148 cancelled: &AtomicBool,
1149 ) -> Result<(Arc<SqlGroupCommitJob>, bool), NodeError> {
1150 if cancelled.load(Ordering::Acquire) {
1151 return Err(NodeError::Unavailable(
1152 "SQL group commit is unavailable during shutdown".into(),
1153 ));
1154 }
1155 let job = Arc::new(SqlGroupCommitJob::new(members));
1156 if job.encoded_bytes > MAX_COMMAND_BYTES {
1157 return Err(NodeError::ResourceExhausted(format!(
1158 "SQL group commit call exceeds {MAX_COMMAND_BYTES} encoded bytes"
1159 )));
1160 }
1161 let mut state = self
1162 .state
1163 .lock()
1164 .unwrap_or_else(std::sync::PoisonError::into_inner);
1165 if state.pending.len() >= self.capacity {
1166 return Err(NodeError::ResourceExhausted(format!(
1167 "SQL group commit queue is full (capacity {})",
1168 self.capacity
1169 )));
1170 }
1171 let pending_encoded_bytes = state
1172 .pending_encoded_bytes
1173 .saturating_add(job.encoded_bytes);
1174 if pending_encoded_bytes > MAX_SQL_GROUP_COMMIT_PENDING_BYTES {
1175 return Err(NodeError::ResourceExhausted(format!(
1176 "SQL group commit queue exceeds {MAX_SQL_GROUP_COMMIT_PENDING_BYTES} pending encoded bytes"
1177 )));
1178 }
1179 state.pending_encoded_bytes = pending_encoded_bytes;
1180 state.pending.push_back(Arc::clone(&job));
1181 self.changed.notify_all();
1182 let leader = !state.leader_active;
1183 if leader {
1184 state.leader_active = true;
1185 }
1186 Ok((job, leader))
1187 }
1188
1189 fn drain_next_group(&self) -> Option<Vec<Arc<SqlGroupCommitJob>>> {
1190 let mut state = self
1191 .state
1192 .lock()
1193 .unwrap_or_else(std::sync::PoisonError::into_inner);
1194 if state.pending.is_empty() {
1195 state.leader_active = false;
1196 return None;
1197 }
1198 let mut member_count = 0_usize;
1199 let mut encoded_bytes = 0_usize;
1200 let mut jobs = Vec::new();
1201 while let Some(job) = state.pending.front() {
1202 let next_count = member_count.saturating_add(job.member_count);
1203 let next_encoded_bytes = encoded_bytes.saturating_add(job.encoded_bytes);
1204 if !jobs.is_empty()
1205 && (next_count > MAX_SQL_WRITE_BATCH_MEMBERS
1206 || next_encoded_bytes > MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES)
1207 {
1208 break;
1209 }
1210 let job = state.pending.pop_front().expect("front job exists");
1211 member_count = next_count;
1212 encoded_bytes = next_encoded_bytes;
1213 state.pending_encoded_bytes = state
1214 .pending_encoded_bytes
1215 .checked_sub(job.encoded_bytes)
1216 .expect("queued SQL byte reservation covers every pending job");
1217 jobs.push(job);
1218 }
1219 Some(jobs)
1220 }
1221
1222 fn collect_until_full_or_timeout(&self, timeout: Duration) -> bool {
1223 let mut state = self
1224 .state
1225 .lock()
1226 .unwrap_or_else(std::sync::PoisonError::into_inner);
1227 if state.pending.is_empty() {
1228 state.leader_active = false;
1229 return false;
1230 }
1231 let hard_deadline = Instant::now() + timeout.saturating_mul(4);
1232 loop {
1233 let member_count = state
1234 .pending
1235 .iter()
1236 .fold(0_usize, |count, job| count.saturating_add(job.member_count));
1237 if member_count >= MAX_SQL_WRITE_BATCH_MEMBERS
1238 || state.pending_encoded_bytes >= MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES
1239 {
1240 return true;
1241 }
1242 let remaining = hard_deadline.saturating_duration_since(Instant::now());
1243 if remaining.is_zero() {
1244 return true;
1245 }
1246 let observed_calls = state.pending.len();
1247 let (next, quiet) = self
1248 .changed
1249 .wait_timeout_while(state, timeout.min(remaining), |state| {
1250 state.pending.len() == observed_calls
1251 && state
1252 .pending
1253 .iter()
1254 .fold(0_usize, |count, job| count.saturating_add(job.member_count))
1255 < MAX_SQL_WRITE_BATCH_MEMBERS
1256 && state.pending_encoded_bytes < MAX_SQL_GROUP_COMMIT_ACTIVE_BYTES
1257 })
1258 .unwrap_or_else(std::sync::PoisonError::into_inner);
1259 state = next;
1260 if quiet.timed_out() {
1261 return true;
1262 }
1263 }
1264 }
1265
1266 fn fail_pending(&self, error: NodeError) {
1267 let jobs = {
1268 let mut state = self
1269 .state
1270 .lock()
1271 .unwrap_or_else(std::sync::PoisonError::into_inner);
1272 state.leader_active = false;
1273 state.pending_encoded_bytes = 0;
1274 state.pending.drain(..).collect::<Vec<_>>()
1275 };
1276 for job in jobs {
1277 job.publish(Err(error.clone()));
1278 }
1279 }
1280
1281 #[cfg(test)]
1282 fn wait_for_pending_calls(&self, expected: usize, timeout: Duration) {
1283 let state = self
1284 .state
1285 .lock()
1286 .unwrap_or_else(std::sync::PoisonError::into_inner);
1287 let (state, timed_out) = self
1288 .changed
1289 .wait_timeout_while(state, timeout, |state| state.pending.len() != expected)
1290 .unwrap_or_else(std::sync::PoisonError::into_inner);
1291 assert!(
1292 !timed_out.timed_out(),
1293 "expected {expected} pending SQL group commit calls, got {}",
1294 state.pending.len()
1295 );
1296 }
1297}
1298
1299#[cfg(feature = "kv")]
1300type KvGroupCommitResult = Result<Vec<Result<ClientWriteResponse, NodeError>>, NodeError>;
1301
1302#[cfg(feature = "kv")]
1303struct KvGroupCommitJob {
1304 member_count: usize,
1305 encoded_bytes: usize,
1306 members: Mutex<Option<Vec<RuntimeBatchMember>>>,
1307 result: Mutex<Option<KvGroupCommitResult>>,
1308 changed: Condvar,
1309}
1310
1311#[cfg(feature = "kv")]
1312impl KvGroupCommitJob {
1313 fn new(members: Vec<RuntimeBatchMember>) -> Self {
1314 Self {
1315 member_count: members.len(),
1316 encoded_bytes: members.iter().fold(0_usize, |bytes, member| {
1317 bytes.saturating_add(member.payload.len())
1318 }),
1319 members: Mutex::new(Some(members)),
1320 result: Mutex::new(None),
1321 changed: Condvar::new(),
1322 }
1323 }
1324
1325 fn take_members(&self) -> Result<Vec<RuntimeBatchMember>, NodeError> {
1326 self.members
1327 .lock()
1328 .unwrap_or_else(std::sync::PoisonError::into_inner)
1329 .take()
1330 .ok_or_else(|| {
1331 NodeError::Invariant("KV group commit job members were already consumed".into())
1332 })
1333 }
1334
1335 fn publish(&self, result: KvGroupCommitResult) {
1336 let mut slot = self
1337 .result
1338 .lock()
1339 .unwrap_or_else(std::sync::PoisonError::into_inner);
1340 if slot.is_none() {
1341 *slot = Some(result);
1342 self.changed.notify_all();
1343 }
1344 }
1345
1346 fn wait(&self, cancelled: &AtomicBool) -> KvGroupCommitResult {
1347 let mut result = self
1348 .result
1349 .lock()
1350 .unwrap_or_else(std::sync::PoisonError::into_inner);
1351 loop {
1352 if let Some(result) = result.take() {
1353 return result;
1354 }
1355 if cancelled.load(Ordering::Acquire) {
1356 return Err(NodeError::Unavailable(
1357 "KV group commit cancelled during shutdown".into(),
1358 ));
1359 }
1360 result = self
1361 .changed
1362 .wait(result)
1363 .unwrap_or_else(std::sync::PoisonError::into_inner);
1364 }
1365 }
1366}
1367
1368#[cfg(feature = "kv")]
1369struct KvGroupCommitQueue {
1370 state: Mutex<KvGroupCommitQueueState>,
1371 changed: Condvar,
1372}
1373
1374#[cfg(feature = "kv")]
1375#[derive(Default)]
1376struct KvGroupCommitQueueState {
1377 pending: VecDeque<Arc<KvGroupCommitJob>>,
1378 pending_encoded_bytes: usize,
1379 leader_active: bool,
1380}
1381
1382#[cfg(feature = "kv")]
1383impl KvGroupCommitQueue {
1384 fn new() -> Self {
1385 Self {
1386 state: Mutex::new(KvGroupCommitQueueState::default()),
1387 changed: Condvar::new(),
1388 }
1389 }
1390
1391 fn enqueue(
1392 &self,
1393 members: Vec<RuntimeBatchMember>,
1394 cancelled: &AtomicBool,
1395 ) -> Result<(Arc<KvGroupCommitJob>, bool), NodeError> {
1396 if cancelled.load(Ordering::Acquire) {
1397 return Err(NodeError::Unavailable(
1398 "KV group commit is unavailable during shutdown".into(),
1399 ));
1400 }
1401 let job = Arc::new(KvGroupCommitJob::new(members));
1402 if job.member_count == 0 || job.member_count > MAX_KV_BATCH_MEMBERS {
1403 return Err(NodeError::InvalidRequest(format!(
1404 "KV group commit call must contain 1..={MAX_KV_BATCH_MEMBERS} members"
1405 )));
1406 }
1407 if job.encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES {
1408 return Err(NodeError::ResourceExhausted(format!(
1409 "KV group commit call exceeds {MAX_KV_GROUP_COMMIT_PENDING_BYTES} encoded bytes"
1410 )));
1411 }
1412 let mut state = self
1413 .state
1414 .lock()
1415 .unwrap_or_else(std::sync::PoisonError::into_inner);
1416 if state.pending.len() >= KV_GROUP_COMMIT_QUEUE_CAPACITY {
1417 return Err(NodeError::ResourceExhausted(format!(
1418 "KV group commit queue is full (capacity {KV_GROUP_COMMIT_QUEUE_CAPACITY})"
1419 )));
1420 }
1421 let pending_encoded_bytes = state
1422 .pending_encoded_bytes
1423 .saturating_add(job.encoded_bytes);
1424 if pending_encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES {
1425 return Err(NodeError::ResourceExhausted(format!(
1426 "KV group commit queue exceeds {MAX_KV_GROUP_COMMIT_PENDING_BYTES} pending encoded bytes"
1427 )));
1428 }
1429 state.pending_encoded_bytes = pending_encoded_bytes;
1430 state.pending.push_back(Arc::clone(&job));
1431 self.changed.notify_all();
1432 let leader = !state.leader_active;
1433 if leader {
1434 state.leader_active = true;
1435 }
1436 Ok((job, leader))
1437 }
1438
1439 fn drain_next_group(&self) -> Option<Vec<Arc<KvGroupCommitJob>>> {
1440 let mut state = self
1441 .state
1442 .lock()
1443 .unwrap_or_else(std::sync::PoisonError::into_inner);
1444 if state.pending.is_empty() {
1445 state.leader_active = false;
1446 return None;
1447 }
1448 let mut member_count = 0_usize;
1449 let mut encoded_bytes = 0_usize;
1450 let mut jobs = Vec::new();
1451 while let Some(job) = state.pending.front() {
1452 let next_count = member_count.saturating_add(job.member_count);
1453 let next_encoded_bytes = encoded_bytes.saturating_add(job.encoded_bytes);
1454 if !jobs.is_empty()
1455 && (next_count > MAX_KV_GROUP_COMMIT_MEMBERS
1456 || next_encoded_bytes > MAX_KV_GROUP_COMMIT_PENDING_BYTES)
1457 {
1458 break;
1459 }
1460 let job = state.pending.pop_front().expect("front job exists");
1461 member_count = next_count;
1462 encoded_bytes = next_encoded_bytes;
1463 state.pending_encoded_bytes = state
1464 .pending_encoded_bytes
1465 .checked_sub(job.encoded_bytes)
1466 .expect("queued KV byte reservation covers every pending job");
1467 jobs.push(job);
1468 }
1469 Some(jobs)
1470 }
1471
1472 fn collect_until_full_or_timeout(&self, timeout: Duration) -> bool {
1473 let mut state = self
1474 .state
1475 .lock()
1476 .unwrap_or_else(std::sync::PoisonError::into_inner);
1477 if state.pending.is_empty() {
1478 state.leader_active = false;
1479 return false;
1480 }
1481 let hard_deadline = Instant::now() + timeout.saturating_mul(4);
1482 loop {
1483 let member_count = state
1484 .pending
1485 .iter()
1486 .fold(0_usize, |count, job| count.saturating_add(job.member_count));
1487 if member_count >= MAX_KV_GROUP_COMMIT_MEMBERS
1488 || state.pending_encoded_bytes >= MAX_KV_GROUP_COMMIT_PENDING_BYTES
1489 {
1490 return true;
1491 }
1492 let remaining = hard_deadline.saturating_duration_since(Instant::now());
1493 if remaining.is_zero() {
1494 return true;
1495 }
1496 let observed_calls = state.pending.len();
1497 let (next, quiet) = self
1498 .changed
1499 .wait_timeout_while(state, timeout.min(remaining), |state| {
1500 state.pending.len() == observed_calls
1501 && state
1502 .pending
1503 .iter()
1504 .fold(0_usize, |count, job| count.saturating_add(job.member_count))
1505 < MAX_KV_GROUP_COMMIT_MEMBERS
1506 && state.pending_encoded_bytes < MAX_KV_GROUP_COMMIT_PENDING_BYTES
1507 })
1508 .unwrap_or_else(std::sync::PoisonError::into_inner);
1509 state = next;
1510 if quiet.timed_out() {
1511 return true;
1512 }
1513 }
1514 }
1515
1516 fn fail_pending(&self, error: NodeError) {
1517 let jobs = {
1518 let mut state = self
1519 .state
1520 .lock()
1521 .unwrap_or_else(std::sync::PoisonError::into_inner);
1522 state.leader_active = false;
1523 state.pending_encoded_bytes = 0;
1524 state.pending.drain(..).collect::<Vec<_>>()
1525 };
1526 for job in jobs {
1527 job.publish(Err(error.clone()));
1528 }
1529 }
1530
1531 #[cfg(test)]
1532 fn wait_for_pending_calls(&self, expected: usize, timeout: Duration) {
1533 let state = self
1534 .state
1535 .lock()
1536 .unwrap_or_else(std::sync::PoisonError::into_inner);
1537 let (state, timed_out) = self
1538 .changed
1539 .wait_timeout_while(state, timeout, |state| state.pending.len() != expected)
1540 .unwrap_or_else(std::sync::PoisonError::into_inner);
1541 assert!(
1542 !timed_out.timed_out(),
1543 "expected {expected} pending KV group commit calls, got {}",
1544 state.pending.len()
1545 );
1546 }
1547}
1548
1549#[cfg(any(feature = "graph", feature = "kv"))]
1550fn classify_pending_request(
1551 canonical_by_request: &mut HashMap<String, usize>,
1552 members: &[RuntimeBatchMember],
1553 index: usize,
1554 request_id: &str,
1555) -> Result<Option<usize>, NodeError> {
1556 let Some(&canonical) = canonical_by_request.get(request_id) else {
1557 canonical_by_request.insert(request_id.to_owned(), index);
1558 return Ok(None);
1559 };
1560 if members[canonical].payload == members[index].payload {
1561 Ok(Some(canonical))
1562 } else {
1563 Err(NodeError::InvalidRequest(format!(
1564 "request id {request_id:?} was reused with another command in the same writer batch"
1565 )))
1566 }
1567}
1568
1569impl ClientWriteResponse {
1570 fn applied_index(&self) -> LogIndex {
1571 match self {
1572 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
1573 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
1574 #[cfg(feature = "sql")]
1575 Self::KeyValue(response) => response.applied_index,
1576 #[cfg(feature = "sql")]
1577 Self::Sql(response) => response.applied_index,
1578 #[cfg(feature = "graph")]
1579 Self::Graph(response) => response.applied_index(),
1580 #[cfg(feature = "kv")]
1581 Self::Kv(response) => response.applied_index(),
1582 }
1583 }
1584}
1585
1586#[cfg(feature = "sql")]
1587#[derive(Clone)]
1588pub struct NodeService {
1589 runtime: Arc<NodeRuntime>,
1590 coordinator: Option<Arc<CheckpointCoordinator>>,
1591 #[cfg(feature = "sql")]
1592 sql_reads_in_flight: Arc<AtomicUsize>,
1593}
1594
1595#[cfg(feature = "sql")]
1596struct SqlReadActivity {
1597 active: Arc<AtomicUsize>,
1598}
1599
1600#[cfg(feature = "sql")]
1601impl SqlReadActivity {
1602 fn enter(active: &Arc<AtomicUsize>) -> (Self, bool) {
1603 let previous = active.fetch_add(1, Ordering::Relaxed);
1604 debug_assert_ne!(previous, usize::MAX);
1605 (
1606 Self {
1607 active: active.clone(),
1608 },
1609 previous == 0,
1610 )
1611 }
1612}
1613
1614#[cfg(feature = "sql")]
1615impl Drop for SqlReadActivity {
1616 fn drop(&mut self) {
1617 let previous = self.active.fetch_sub(1, Ordering::Relaxed);
1618 debug_assert!(previous > 0);
1619 }
1620}
1621
1622#[cfg(feature = "sql")]
1623impl NodeService {
1624 pub fn new(runtime: Arc<NodeRuntime>, coordinator: Option<Arc<CheckpointCoordinator>>) -> Self {
1625 Self {
1626 runtime,
1627 coordinator,
1628 #[cfg(feature = "sql")]
1629 sql_reads_in_flight: Arc::new(AtomicUsize::new(0)),
1630 }
1631 }
1632
1633 #[cfg(feature = "sql")]
1634 pub async fn put(
1635 &self,
1636 request_id: &str,
1637 key: &str,
1638 value: &str,
1639 ) -> Result<WriteResponse, NodeError> {
1640 self.write(WriteRequest {
1641 request_id: request_id.into(),
1642 key: key.into(),
1643 value: value.into(),
1644 })
1645 .await
1646 }
1647
1648 #[cfg(feature = "sql")]
1649 pub async fn write(&self, request: WriteRequest) -> Result<WriteResponse, NodeError> {
1650 self.write_allowed()?;
1651 let runtime = self.runtime.clone();
1652 let response = tokio::task::spawn_blocking(move || {
1653 runtime.write(&request.request_id, &request.key, &request.value)
1654 })
1655 .await
1656 .map_err(node_service_task_error)??;
1657 self.confirm_committed(response.applied_index).await?;
1658 Ok(response)
1659 }
1660
1661 #[cfg(feature = "sql")]
1662 pub async fn execute_sql(&self, command: SqlCommand) -> Result<SqlExecuteResponse, NodeError> {
1663 self.write_allowed()?;
1664 let runtime = self.runtime.clone();
1665 let response =
1666 tokio::task::spawn_blocking(move || runtime.execute_sql_with_results(command))
1667 .await
1668 .map_err(node_service_task_error)??;
1669 self.confirm_committed(response.applied_index).await?;
1670 Ok(response)
1671 }
1672
1673 #[cfg(feature = "sql")]
1674 pub async fn read(
1675 &self,
1676 key: &str,
1677 consistency: ReadConsistency,
1678 ) -> Result<ReadResponse, NodeError> {
1679 let runtime = self.runtime.clone();
1680 let key = key.to_owned();
1681 self.run_sql_read_operation(consistency, move || runtime.read(&key, consistency))
1682 .await
1683 .map_err(node_service_task_error)?
1684 }
1685
1686 #[cfg(feature = "sql")]
1687 pub async fn query(
1688 &self,
1689 statement: SqlStatement,
1690 consistency: ReadConsistency,
1691 max_rows: u32,
1692 ) -> Result<SqlQueryResponse, NodeError> {
1693 let runtime = self.runtime.clone();
1694 self.run_sql_read_operation(consistency, move || {
1695 runtime.query_sql(&statement, consistency, max_rows)
1696 })
1697 .await
1698 .map_err(node_service_task_error)?
1699 }
1700
1701 #[cfg(feature = "sql")]
1702 async fn run_sql_read_operation<F, T>(
1703 &self,
1704 consistency: ReadConsistency,
1705 operation: F,
1706 ) -> Result<T, tokio::task::JoinError>
1707 where
1708 F: FnOnce() -> T + Send + 'static,
1709 T: Send + 'static,
1710 {
1711 if consistency == ReadConsistency::ReadBarrier {
1712 return run_read_operation(consistency, operation).await;
1713 }
1714 let (activity, sole_read) = SqlReadActivity::enter(&self.sql_reads_in_flight);
1715 let operation = move || {
1716 let _activity = activity;
1717 operation()
1718 };
1719 if sole_read {
1720 run_read_operation(consistency, operation).await
1721 } else {
1722 tokio::task::spawn_blocking(operation).await
1723 }
1724 }
1725
1726 #[cfg(feature = "sql")]
1727 fn write_allowed(&self) -> Result<(), NodeError> {
1728 self.coordinator
1729 .as_ref()
1730 .map_or(Ok(()), |coordinator| coordinator.write_allowed())
1731 .map_err(|error| NodeError::Unavailable(error.to_string()))
1732 }
1733
1734 #[cfg(feature = "sql")]
1735 async fn confirm_committed(&self, index: LogIndex) -> Result<(), NodeError> {
1736 confirm_write_durability(self.runtime.as_ref(), self.coordinator.as_deref(), index)
1737 .await
1738 .map_err(|error| NodeError::Unavailable(error.to_string()))
1739 }
1740}
1741
1742#[cfg(feature = "sql")]
1743fn node_service_task_error(error: tokio::task::JoinError) -> NodeError {
1744 NodeError::Fatal(format!("node service task failed: {error}"))
1745}
1746
1747#[doc(hidden)]
1748pub async fn run_read_operation<F, T>(
1749 consistency: ReadConsistency,
1750 operation: F,
1751) -> Result<T, tokio::task::JoinError>
1752where
1753 F: FnOnce() -> T + Send + 'static,
1754 T: Send + 'static,
1755{
1756 if consistency != ReadConsistency::ReadBarrier
1757 && matches!(
1758 tokio::runtime::Handle::current().runtime_flavor(),
1759 tokio::runtime::RuntimeFlavor::MultiThread
1760 )
1761 {
1762 Ok(tokio::task::block_in_place(operation))
1763 } else {
1764 tokio::task::spawn_blocking(operation).await
1765 }
1766}
1767
1768#[derive(Clone)]
1769struct PeerGateState {
1770 peers: Vec<PeerConfig>,
1771 recovery_generation: u64,
1772 protocol_version: &'static str,
1773 slots: Arc<tokio::sync::Semaphore>,
1774}
1775
1776#[derive(Clone)]
1777struct ClientGateState {
1778 runtime: Arc<NodeRuntime>,
1779 slots: Arc<tokio::sync::Semaphore>,
1780 coordinator: Option<Arc<CheckpointCoordinator>>,
1781}
1782
1783#[derive(Clone)]
1784struct RuntimeLogPeer {
1785 runtime: Arc<NodeRuntime>,
1786}
1787
1788impl LogPeer for RuntimeLogPeer {
1789 fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
1790 self.runtime.fetch_log(request)
1791 }
1792}
1793
1794fn fetch_runtime_log(
1795 runtime: &NodeRuntime,
1796 request: FetchLogRequest,
1797) -> Result<FetchLogResponse, FetchLogError> {
1798 if request.from_index == 0 || request.max_entries > MAX_FETCH_ENTRIES {
1799 return Err(FetchLogError::InvalidRequest {
1800 message: "invalid fetch bounds".into(),
1801 });
1802 }
1803 let state = runtime
1804 .log_store
1805 .logical_state()
1806 .map_err(|error| FetchLogError::Transport {
1807 message: error.to_string(),
1808 })?;
1809 if let Some(anchor) = state.anchor {
1810 if request.from_index <= anchor.compacted().index() {
1811 return Err(FetchLogError::SnapshotRequired {
1812 anchor: Box::new(anchor),
1813 });
1814 }
1815 }
1816 let last_index = state.tip.map_or(0, |tip| tip.index());
1817 if request.max_entries == 0 || request.from_index > last_index {
1818 return Ok(FetchLogResponse {
1819 entries: Vec::new(),
1820 last_index,
1821 });
1822 }
1823 let end = request
1824 .from_index
1825 .saturating_add(u64::from(request.max_entries) - 1)
1826 .min(last_index);
1827 let range = IndexRange::new(request.from_index, end).map_err(|error| {
1828 FetchLogError::InvalidRequest {
1829 message: error.to_string(),
1830 }
1831 })?;
1832 let entries =
1833 runtime
1834 .log_store
1835 .read_range(range)
1836 .map_err(|error| FetchLogError::Transport {
1837 message: error.to_string(),
1838 })?;
1839 Ok(FetchLogResponse {
1840 entries,
1841 last_index,
1842 })
1843}
1844
1845pub fn recorder_router<R, P>(recorder: R, peers: P) -> Router
1846where
1847 R: RecorderRpc + Clone + Send + Sync + 'static,
1848 P: Into<Vec<PeerConfig>>,
1849{
1850 recorder_router_for_generation(recorder, peers, 1)
1851}
1852
1853pub fn recorder_router_for_generation<R, P>(
1854 recorder: R,
1855 peers: P,
1856 recovery_generation: u64,
1857) -> Router
1858where
1859 R: RecorderRpc + Clone + Send + Sync + 'static,
1860 P: Into<Vec<PeerConfig>>,
1861{
1862 recorder_routes(
1863 recorder,
1864 peers.into(),
1865 recovery_generation,
1866 Arc::new(tokio::sync::Semaphore::new(DEFAULT_PEER_CONCURRENCY)),
1867 )
1868 .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
1869}
1870
1871pub fn log_peer_router<P, C>(peer: P, peers: C) -> Router
1872where
1873 P: LogPeer + Clone + Send + Sync + 'static,
1874 C: Into<Vec<PeerConfig>>,
1875{
1876 log_peer_router_for_generation(peer, peers, 1)
1877}
1878
1879pub fn log_peer_router_for_generation<P, C>(peer: P, peers: C, recovery_generation: u64) -> Router
1880where
1881 P: LogPeer + Clone + Send + Sync + 'static,
1882 C: Into<Vec<PeerConfig>>,
1883{
1884 log_routes(
1885 peer,
1886 peers.into(),
1887 recovery_generation,
1888 Arc::new(tokio::sync::Semaphore::new(DEFAULT_PEER_CONCURRENCY)),
1889 )
1890 .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
1891}
1892
1893pub fn node_rpc_router<R, P, C>(recorder: R, peer: P, peers: C) -> Router
1894where
1895 R: RecorderRpc + Clone + Send + Sync + 'static,
1896 P: LogPeer + Clone + Send + Sync + 'static,
1897 C: Into<Vec<PeerConfig>>,
1898{
1899 node_rpc_router_for_generation(recorder, peer, peers, 1)
1900}
1901
1902pub fn node_rpc_router_for_generation<R, P, C>(
1903 recorder: R,
1904 peer: P,
1905 peers: C,
1906 recovery_generation: u64,
1907) -> Router
1908where
1909 R: RecorderRpc + Clone + Send + Sync + 'static,
1910 P: LogPeer + Clone + Send + Sync + 'static,
1911 C: Into<Vec<PeerConfig>>,
1912{
1913 node_rpc_router_with_limits_for_generation(
1914 recorder,
1915 peer,
1916 peers,
1917 DEFAULT_PEER_CONCURRENCY,
1918 DEFAULT_PEER_CONCURRENCY,
1919 recovery_generation,
1920 )
1921}
1922
1923pub fn node_rpc_router_with_limits<R, P, C>(
1924 recorder: R,
1925 peer: P,
1926 peers: C,
1927 recorder_concurrency: usize,
1928 log_concurrency: usize,
1929) -> Router
1930where
1931 R: RecorderRpc + Clone + Send + Sync + 'static,
1932 P: LogPeer + Clone + Send + Sync + 'static,
1933 C: Into<Vec<PeerConfig>>,
1934{
1935 node_rpc_router_with_limits_for_generation(
1936 recorder,
1937 peer,
1938 peers,
1939 recorder_concurrency,
1940 log_concurrency,
1941 1,
1942 )
1943}
1944
1945pub fn node_rpc_router_with_limits_for_generation<R, P, C>(
1946 recorder: R,
1947 peer: P,
1948 peers: C,
1949 recorder_concurrency: usize,
1950 log_concurrency: usize,
1951 recovery_generation: u64,
1952) -> Router
1953where
1954 R: RecorderRpc + Clone + Send + Sync + 'static,
1955 P: LogPeer + Clone + Send + Sync + 'static,
1956 C: Into<Vec<PeerConfig>>,
1957{
1958 let peers = peers.into();
1959 let recorder_slots = Arc::new(tokio::sync::Semaphore::new(recorder_concurrency));
1960 let log_slots = Arc::new(tokio::sync::Semaphore::new(log_concurrency));
1961 recorder_routes(recorder, peers.clone(), recovery_generation, recorder_slots)
1962 .merge(log_routes(peer, peers, recovery_generation, log_slots))
1963 .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
1964}
1965
1966pub fn node_router<R>(runtime: Arc<NodeRuntime>, recorder: R) -> Router
1967where
1968 R: RecorderRpc + Clone + Send + Sync + 'static,
1969{
1970 node_router_with_limits(
1971 runtime,
1972 recorder,
1973 DEFAULT_CLIENT_CONCURRENCY,
1974 DEFAULT_PEER_CONCURRENCY,
1975 )
1976}
1977
1978pub fn node_router_with_limits<R>(
1979 runtime: Arc<NodeRuntime>,
1980 recorder: R,
1981 client_concurrency: usize,
1982 peer_concurrency: usize,
1983) -> Router
1984where
1985 R: RecorderRpc + Clone + Send + Sync + 'static,
1986{
1987 node_router_with_optional_checkpoint(
1988 runtime,
1989 recorder,
1990 None,
1991 client_concurrency,
1992 peer_concurrency,
1993 )
1994}
1995
1996pub fn node_router_with_checkpoint<R>(
1997 runtime: Arc<NodeRuntime>,
1998 recorder: R,
1999 coordinator: Arc<CheckpointCoordinator>,
2000) -> Router
2001where
2002 R: RecorderRpc + Clone + Send + Sync + 'static,
2003{
2004 node_router_with_checkpoint_and_limits(
2005 runtime,
2006 recorder,
2007 coordinator,
2008 DEFAULT_CLIENT_CONCURRENCY,
2009 DEFAULT_PEER_CONCURRENCY,
2010 )
2011}
2012
2013pub fn node_router_with_checkpoint_and_limits<R>(
2014 runtime: Arc<NodeRuntime>,
2015 recorder: R,
2016 coordinator: Arc<CheckpointCoordinator>,
2017 client_concurrency: usize,
2018 peer_concurrency: usize,
2019) -> Router
2020where
2021 R: RecorderRpc + Clone + Send + Sync + 'static,
2022{
2023 node_router_with_optional_checkpoint(
2024 runtime,
2025 recorder,
2026 Some(coordinator),
2027 client_concurrency,
2028 peer_concurrency,
2029 )
2030}
2031
2032fn node_router_with_optional_checkpoint<R>(
2033 runtime: Arc<NodeRuntime>,
2034 recorder: R,
2035 coordinator: Option<Arc<CheckpointCoordinator>>,
2036 client_concurrency: usize,
2037 peer_concurrency: usize,
2038) -> Router
2039where
2040 R: RecorderRpc + Clone + Send + Sync + 'static,
2041{
2042 let peers = runtime.config.peers.clone();
2043 let recovery_generation = runtime.config.recovery_generation();
2044 let client_slots = Arc::new(tokio::sync::Semaphore::new(client_concurrency));
2045 let recorder_slots = Arc::new(tokio::sync::Semaphore::new(peer_concurrency));
2046 let log_slots = Arc::new(tokio::sync::Semaphore::new(peer_concurrency));
2047 let write_operations = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
2048 let (writer, writer_receiver) = tokio::sync::mpsc::channel(client_concurrency.max(1));
2049 tokio::spawn(writer_loop(
2050 Arc::downgrade(&runtime),
2051 coordinator.clone(),
2052 write_operations.clone(),
2053 writer_receiver,
2054 runtime.config.writer_batch_max,
2055 runtime.config.writer_batch_window,
2056 ));
2057 #[cfg(any(feature = "sql", feature = "graph", feature = "kv"))]
2058 let client_routes: Router = match runtime.config().execution_profile() {
2059 ExecutionProfile::Sqlite => {
2060 #[cfg(feature = "sql")]
2061 {
2062 Router::new()
2063 .route(WRITE_PATH, post(handle_write))
2064 .route(READ_PATH, post(handle_read))
2065 .route(SQL_EXECUTE_PATH, post(handle_sql_execute))
2066 .route(SQL_QUERY_PATH, post(handle_sql_query))
2067 }
2068 #[cfg(not(feature = "sql"))]
2069 unreachable!("SQL runtime cannot open without the sql feature")
2070 }
2071 ExecutionProfile::Graph => {
2072 #[cfg(feature = "graph")]
2073 {
2074 Router::new()
2075 .route(GRAPH_PUT_DOCUMENT_PATH, post(handle_graph_put_document))
2076 .route(
2077 GRAPH_DELETE_DOCUMENT_PATH,
2078 post(handle_graph_delete_document),
2079 )
2080 .route(GRAPH_GET_DOCUMENT_PATH, post(handle_graph_get_document))
2081 .route(GRAPH_QUERY_PATH, post(handle_graph_query))
2082 }
2083 #[cfg(not(feature = "graph"))]
2084 unreachable!("graph runtime cannot open without the graph feature")
2085 }
2086 ExecutionProfile::Kv => {
2087 #[cfg(feature = "kv")]
2088 {
2089 Router::new()
2090 .route(KV_PUT_PATH, post(handle_kv_put))
2091 .route(KV_DELETE_PATH, post(handle_kv_delete))
2092 .route(KV_GET_PATH, post(handle_kv_get))
2093 .route(KV_SCAN_PATH, post(handle_kv_scan))
2094 }
2095 #[cfg(not(feature = "kv"))]
2096 unreachable!("KV runtime cannot open without the kv feature")
2097 }
2098 }
2099 .route_layer(middleware::from_fn_with_state(
2100 ClientGateState {
2101 runtime: runtime.clone(),
2102 slots: client_slots,
2103 coordinator: coordinator.clone(),
2104 },
2105 client_gate,
2106 ))
2107 .with_state(NodeRouteState {
2108 runtime: runtime.clone(),
2109 coordinator: coordinator.clone(),
2110 write_operations: write_operations.clone(),
2111 writer: writer.clone(),
2112 });
2113 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
2114 let client_routes: Router = Router::new();
2115 let health_routes = Router::new()
2116 .route(LIVEZ_PATH, get(handle_livez))
2117 .route(READYZ_PATH, get(handle_readyz))
2118 .with_state(NodeRouteState {
2119 runtime: runtime.clone(),
2120 coordinator,
2121 write_operations,
2122 writer,
2123 });
2124 recorder_routes(recorder, peers.clone(), recovery_generation, recorder_slots)
2125 .merge(log_routes(
2126 RuntimeLogPeer { runtime },
2127 peers,
2128 recovery_generation,
2129 log_slots,
2130 ))
2131 .merge(client_routes)
2132 .merge(health_routes)
2133 .layer(DefaultBodyLimit::max(MAX_HTTP_BODY_BYTES))
2134}
2135
2136fn recorder_routes<R>(
2137 recorder: R,
2138 peers: Vec<PeerConfig>,
2139 recovery_generation: u64,
2140 slots: Arc<tokio::sync::Semaphore>,
2141) -> Router
2142where
2143 R: RecorderRpc + Clone + Send + Sync + 'static,
2144{
2145 let recorder_peers = peers.clone();
2146 Router::new()
2147 .route(RECORDER_IDENTITY_PATH, post(handle_recorder_identity::<R>))
2148 .route(
2149 RECORDER_STORE_COMMAND_PATH,
2150 post(handle_recorder_store_command::<R>),
2151 )
2152 .route(
2153 RECORDER_FETCH_COMMAND_PATH,
2154 post(handle_recorder_fetch_command::<R>),
2155 )
2156 .route(
2157 RECORDER_INSPECT_PROOF_PATH,
2158 post(handle_recorder_inspect_proof::<R>),
2159 )
2160 .route(
2161 RECORDER_INSPECT_RECORD_PATH,
2162 post(handle_recorder_inspect_record::<R>),
2163 )
2164 .route(
2165 RECORDER_READ_FENCE_PATH,
2166 post(handle_recorder_read_fence::<R>),
2167 )
2168 .route(RECORDER_RECORD_PATH, post(handle_recorder_record::<R>))
2169 .route(
2170 RECORDER_INSTALL_PROOF_PATH,
2171 post(handle_recorder_install_proof::<R>),
2172 )
2173 .route_layer(middleware::from_fn_with_state(
2174 PeerGateState {
2175 peers,
2176 recovery_generation,
2177 protocol_version: RECORDER_PROTOCOL_VERSION,
2178 slots,
2179 },
2180 peer_gate,
2181 ))
2182 .with_state(RecorderRouteState {
2183 recorder,
2184 peers: recorder_peers,
2185 })
2186}
2187
2188fn log_routes<P>(
2189 peer: P,
2190 peers: Vec<PeerConfig>,
2191 recovery_generation: u64,
2192 slots: Arc<tokio::sync::Semaphore>,
2193) -> Router
2194where
2195 P: LogPeer + Clone + Send + Sync + 'static,
2196{
2197 Router::new()
2198 .route(LOG_FETCH_PATH, post(handle_fetch_log::<P>))
2199 .route_layer(middleware::from_fn_with_state(
2200 PeerGateState {
2201 peers,
2202 recovery_generation,
2203 protocol_version: PROTOCOL_VERSION,
2204 slots,
2205 },
2206 peer_gate,
2207 ))
2208 .with_state(LogRouteState { peer })
2209}
2210
2211async fn handle_recorder_identity<R>(
2212 State(state): State<RecorderRouteState<R>>,
2213 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2214 Json(request): Json<RecorderWire<()>>,
2215) -> Response
2216where
2217 R: RecorderRpc + Clone + Send + Sync + 'static,
2218{
2219 if request.version != RECORDER_WIRE_VERSION {
2220 return StatusCode::BAD_REQUEST.into_response();
2221 }
2222 let recorder = state.recorder;
2223 recorder_v2_response(
2224 tokio::task::spawn_blocking(move || {
2225 let _permit = permit;
2226 recorder.recorder_id()
2227 })
2228 .await,
2229 )
2230}
2231
2232async fn handle_recorder_store_command<R>(
2233 State(state): State<RecorderRouteState<R>>,
2234 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2235 Json(request): Json<RecorderWire<StoreCommandV2>>,
2236) -> Response
2237where
2238 R: RecorderRpc + Clone + Send + Sync + 'static,
2239{
2240 if request.version != RECORDER_WIRE_VERSION || !valid_recorder_command(&request.body.command) {
2241 return StatusCode::BAD_REQUEST.into_response();
2242 }
2243 let recorder = state.recorder;
2244 recorder_v2_response(
2245 tokio::task::spawn_blocking(move || {
2246 let _permit = permit;
2247 let body = request.body;
2248 recorder.store_command_for(
2249 body.cluster_id,
2250 body.epoch,
2251 body.config_id,
2252 body.config_digest,
2253 body.command_hash,
2254 body.command,
2255 )
2256 })
2257 .await,
2258 )
2259}
2260
2261async fn handle_recorder_fetch_command<R>(
2262 State(state): State<RecorderRouteState<R>>,
2263 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2264 Json(request): Json<RecorderWire<FetchCommandV2>>,
2265) -> Response
2266where
2267 R: RecorderRpc + Clone + Send + Sync + 'static,
2268{
2269 if request.version != RECORDER_WIRE_VERSION {
2270 return StatusCode::BAD_REQUEST.into_response();
2271 }
2272 let recorder = state.recorder;
2273 recorder_v2_response(
2274 tokio::task::spawn_blocking(move || {
2275 let _permit = permit;
2276 let body = request.body;
2277 recorder.fetch_command_for(
2278 body.cluster_id,
2279 body.epoch,
2280 body.config_id,
2281 body.config_digest,
2282 body.command_hash,
2283 )
2284 })
2285 .await,
2286 )
2287}
2288
2289async fn handle_recorder_inspect_proof<R>(
2290 State(state): State<RecorderRouteState<R>>,
2291 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2292 Json(request): Json<RecorderWire<InspectProofV2>>,
2293) -> Response
2294where
2295 R: RecorderRpc + Clone + Send + Sync + 'static,
2296{
2297 if request.version != RECORDER_WIRE_VERSION {
2298 return StatusCode::BAD_REQUEST.into_response();
2299 }
2300 let recorder = state.recorder;
2301 recorder_v2_response(
2302 tokio::task::spawn_blocking(move || {
2303 let _permit = permit;
2304 recorder.inspect_decision_proof(request.body.slot)
2305 })
2306 .await,
2307 )
2308}
2309
2310async fn handle_recorder_inspect_record<R>(
2311 State(state): State<RecorderRouteState<R>>,
2312 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2313 Json(request): Json<RecorderWire<InspectProofV2>>,
2314) -> Response
2315where
2316 R: RecorderRpc + Clone + Send + Sync + 'static,
2317{
2318 if request.version != RECORDER_WIRE_VERSION {
2319 return StatusCode::BAD_REQUEST.into_response();
2320 }
2321 let recorder = state.recorder;
2322 recorder_v2_response(
2323 tokio::task::spawn_blocking(move || {
2324 let _permit = permit;
2325 recorder.inspect_record_summary(request.body.slot)
2326 })
2327 .await,
2328 )
2329}
2330
2331async fn handle_recorder_read_fence<R>(
2332 State(state): State<RecorderRouteState<R>>,
2333 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2334 Json(request): Json<RecorderWire<ReadFenceRequest>>,
2335) -> Response
2336where
2337 R: RecorderRpc + Clone + Send + Sync + 'static,
2338{
2339 if request.version != RECORDER_WIRE_VERSION {
2340 return StatusCode::BAD_REQUEST.into_response();
2341 }
2342 let recorder = state.recorder;
2343 recorder_v2_response(
2344 tokio::task::spawn_blocking(move || {
2345 let _permit = permit;
2346 recorder.observe_read_fence(request.body)
2347 })
2348 .await,
2349 )
2350}
2351
2352async fn handle_recorder_record<R>(
2353 State(state): State<RecorderRouteState<R>>,
2354 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2355 Extension(authenticated_peer): Extension<AuthenticatedPeer>,
2356 Json(request): Json<RecorderWire<RecordRequest>>,
2357) -> Response
2358where
2359 R: RecorderRpc + Clone + Send + Sync + 'static,
2360{
2361 if request.version != RECORDER_WIRE_VERSION || !valid_recorder_record(&request.body) {
2362 return StatusCode::BAD_REQUEST.into_response();
2363 }
2364 if !authenticated_proposer_admitted(
2365 &authenticated_peer.0,
2366 &request.body.proposal.proposer_id,
2367 &state.peers,
2368 ) {
2369 return recorder_v2_response::<RecordSummary>(Ok(Err(rhiza_quepaxa::Error::Rejected(
2370 RejectReason::InvalidRequest,
2371 ))));
2372 }
2373 let recorder = state.recorder;
2374 recorder_v2_response(
2375 tokio::task::spawn_blocking(move || {
2376 let _permit = permit;
2377 recorder.record(request.body)
2378 })
2379 .await,
2380 )
2381}
2382
2383fn valid_recorder_command(command: &StoredCommand) -> bool {
2384 command.payload.len() <= MAX_COMMAND_BYTES
2385}
2386
2387fn valid_recorder_record(request: &RecordRequest) -> bool {
2388 !request.cluster_id.is_empty()
2389 && request.cluster_id.len() <= MAX_REQUEST_ID_BYTES
2390 && request.command.as_ref().is_none_or(valid_recorder_command)
2391}
2392
2393fn authenticated_proposer_admitted(
2394 authenticated_peer_id: &str,
2395 proposer_id: &str,
2396 peers: &[PeerConfig],
2397) -> bool {
2398 peers
2401 .iter()
2402 .any(|peer| peer.node_id == authenticated_peer_id)
2403 && peers.iter().any(|peer| peer.node_id == proposer_id)
2404}
2405
2406async fn handle_recorder_install_proof<R>(
2407 State(state): State<RecorderRouteState<R>>,
2408 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2409 Extension(authenticated_peer): Extension<AuthenticatedPeer>,
2410 Json(request): Json<RecorderWire<InstallProofV2>>,
2411) -> Response
2412where
2413 R: RecorderRpc + Clone + Send + Sync + 'static,
2414{
2415 if request.version != RECORDER_WIRE_VERSION {
2416 return StatusCode::BAD_REQUEST.into_response();
2417 }
2418 if !authenticated_proposer_admitted(
2419 &authenticated_peer.0,
2420 &request.body.proof.proposal().proposer_id,
2421 &state.peers,
2422 ) {
2423 return recorder_v2_response::<()>(Ok(Err(rhiza_quepaxa::Error::Rejected(
2424 RejectReason::InvalidRequest,
2425 ))));
2426 }
2427 let recorder = state.recorder;
2428 recorder_v2_response(
2429 tokio::task::spawn_blocking(move || {
2430 let _permit = permit;
2431 let membership = Membership::from_voters(request.body.members)?;
2432 recorder.install_decision_proof(request.body.proof, &membership)
2433 })
2434 .await,
2435 )
2436}
2437
2438fn recorder_v2_response<T: serde::Serialize>(
2439 result: Result<rhiza_quepaxa::Result<T>, tokio::task::JoinError>,
2440) -> Response {
2441 let (status, body) = match result {
2442 Ok(Ok(value)) => (StatusCode::OK, RecorderV2Result::Ok(value)),
2443 Ok(Err(rhiza_quepaxa::Error::Rejected(reason))) => {
2444 (StatusCode::CONFLICT, RecorderV2Result::Rejected(reason))
2445 }
2446 Ok(Err(error)) => (
2447 recorder_error_status(&error),
2448 RecorderV2Result::Error(error.to_string()),
2449 ),
2450 Err(error) => (
2451 StatusCode::INTERNAL_SERVER_ERROR,
2452 RecorderV2Result::Error(error.to_string()),
2453 ),
2454 };
2455 (
2456 status,
2457 Json(RecorderWire {
2458 version: RECORDER_WIRE_VERSION,
2459 body,
2460 }),
2461 )
2462 .into_response()
2463}
2464
2465async fn handle_fetch_log<P>(
2466 State(state): State<LogRouteState<P>>,
2467 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2468 Json(request): Json<FetchLogRequest>,
2469) -> Response
2470where
2471 P: LogPeer + Clone + Send + Sync + 'static,
2472{
2473 if request.from_index == 0 || request.max_entries > MAX_FETCH_ENTRIES {
2474 return StatusCode::BAD_REQUEST.into_response();
2475 }
2476 let peer = state.peer;
2477 let result = tokio::task::spawn_blocking(move || {
2478 let _permit = permit;
2479 peer.fetch_log(request)
2480 })
2481 .await;
2482 match result {
2483 Ok(Ok(response)) => Json(FetchLogHttpResponse::Fetched(response)).into_response(),
2484 Ok(Err(error)) => (
2485 fetch_log_error_status(&error),
2486 Json(FetchLogHttpResponse::Failed(error)),
2487 )
2488 .into_response(),
2489 Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
2490 }
2491}
2492
2493#[cfg(feature = "sql")]
2494async fn handle_write(
2495 State(state): State<NodeRouteState>,
2496 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2497 request: Result<Json<WriteRequest>, JsonRejection>,
2498) -> Response {
2499 let request = match client_json(request) {
2500 Ok(request) => request,
2501 Err(response) => return response,
2502 };
2503 let payload = match canonical_put(&request.request_id, &request.key, &request.value) {
2504 Ok(payload) => payload,
2505 Err(error) => return node_error_response(error),
2506 };
2507 let request_id = request.request_id.clone();
2508 let operation = QueuedOperation::KeyValue {
2509 key: request.key,
2510 value: request.value,
2511 };
2512 coordinate_write(state, permit, request_id, payload, operation).await
2513}
2514
2515#[cfg(feature = "sql")]
2516async fn handle_sql_execute(
2517 State(state): State<NodeRouteState>,
2518 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2519 request: Result<Json<SqlExecuteRequest>, JsonRejection>,
2520) -> Response {
2521 let request = match client_json(request) {
2522 Ok(request) => request,
2523 Err(response) => return response,
2524 };
2525 if let Err(error) = validate_field(
2526 "request_id",
2527 &request.request_id,
2528 MAX_REQUEST_ID_BYTES,
2529 false,
2530 ) {
2531 return node_error_response(error);
2532 }
2533 let command = SqlCommand {
2534 request_id: request.request_id.clone(),
2535 statements: request.statements,
2536 };
2537 let payload = match encode_sql_command_with_index(&command) {
2538 Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
2539 Ok(_) => {
2540 return node_error_response(NodeError::InvalidRequest(format!(
2541 "command exceeds {MAX_COMMAND_BYTES} bytes"
2542 )))
2543 }
2544 Err(error) => return node_error_response(error),
2545 };
2546 let request_id = command.request_id.clone();
2547 coordinate_write(
2548 state,
2549 permit,
2550 request_id,
2551 payload,
2552 QueuedOperation::Sql(command),
2553 )
2554 .await
2555}
2556
2557async fn coordinate_write(
2558 state: NodeRouteState,
2559 permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2560 request_id: String,
2561 payload: Vec<u8>,
2562 operation: QueuedOperation,
2563) -> Response {
2564 let deadline = tokio::time::Instant::now() + CLIENT_WRITE_WAIT_TIMEOUT;
2565 let (mut receiver, queued) = {
2566 let mut operations = state.write_operations.lock().await;
2567 if let Some(operation) = operations.get(&request_id) {
2568 if operation.payload != payload {
2569 return client_error_response(
2570 StatusCode::CONFLICT,
2571 "request_conflict",
2572 false,
2573 "request id is already in flight with a different payload",
2574 None,
2575 );
2576 }
2577 (operation.result.clone(), None)
2578 } else {
2579 let (sender, receiver) = tokio::sync::watch::channel(None);
2580 operations.insert(
2581 request_id.clone(),
2582 WriteOperation {
2583 payload: payload.clone(),
2584 result: receiver.clone(),
2585 },
2586 );
2587 (
2588 receiver,
2589 Some(QueuedWrite {
2590 request_id: request_id.clone(),
2591 payload,
2592 operation,
2593 permit,
2594 sender,
2595 }),
2596 )
2597 }
2598 };
2599 if let Some(queued) = queued {
2600 match tokio::time::timeout_at(deadline, state.writer.send(queued)).await {
2601 Ok(Ok(())) => {}
2602 Ok(Err(_)) => {
2603 state.write_operations.lock().await.remove(&request_id);
2604 return client_error_response(
2605 StatusCode::SERVICE_UNAVAILABLE,
2606 "durability_unavailable",
2607 true,
2608 "writer queue is unavailable",
2609 None,
2610 );
2611 }
2612 Err(_) => {
2613 state.write_operations.lock().await.remove(&request_id);
2614 return client_error_response(
2615 StatusCode::SERVICE_UNAVAILABLE,
2616 "write_timeout",
2617 true,
2618 "write did not enter the queue before the response deadline",
2619 None,
2620 );
2621 }
2622 }
2623 }
2624 let wait = async {
2625 loop {
2626 if let Some(result) = receiver.borrow().clone() {
2627 return result;
2628 }
2629 if receiver.changed().await.is_err() {
2630 return WriteOperationResult::DurabilityUnavailable;
2631 }
2632 }
2633 };
2634 match tokio::time::timeout_at(deadline, wait).await {
2635 Ok(WriteOperationResult::Runtime(Ok(response))) => match response {
2636 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
2637 ClientWriteResponse::Unavailable => {
2638 unreachable!("no execution profiles are compiled in")
2639 }
2640 #[cfg(feature = "sql")]
2641 ClientWriteResponse::KeyValue(response) => Json(response).into_response(),
2642 #[cfg(feature = "sql")]
2643 ClientWriteResponse::Sql(response) => Json(response).into_response(),
2644 #[cfg(feature = "graph")]
2645 ClientWriteResponse::Graph(outcome) => {
2646 Json(graph_mutation_response(outcome)).into_response()
2647 }
2648 #[cfg(feature = "kv")]
2649 ClientWriteResponse::Kv(outcome) => Json(kv_mutation_response(outcome)).into_response(),
2650 },
2651 Ok(WriteOperationResult::Runtime(Err(error))) => node_error_response(error),
2652 Ok(WriteOperationResult::DurabilityUnavailable) => client_error_response(
2653 StatusCode::SERVICE_UNAVAILABLE,
2654 "durability_unavailable",
2655 true,
2656 "durability confirmation is unavailable",
2657 None,
2658 ),
2659 Err(_) => client_error_response(
2660 StatusCode::SERVICE_UNAVAILABLE,
2661 "write_timeout",
2662 true,
2663 "write did not complete before the response deadline",
2664 None,
2665 ),
2666 }
2667}
2668
2669async fn writer_loop(
2670 runtime: std::sync::Weak<NodeRuntime>,
2671 coordinator: Option<Arc<CheckpointCoordinator>>,
2672 write_operations: Arc<tokio::sync::Mutex<HashMap<String, WriteOperation>>>,
2673 mut receiver: tokio::sync::mpsc::Receiver<QueuedWrite>,
2674 batch_max: usize,
2675 batch_window: Duration,
2676) {
2677 while let Some(first) = receiver.recv().await {
2678 let mut queued = Vec::with_capacity(batch_max);
2679 queued.push(first);
2680 let deadline = tokio::time::Instant::now() + batch_window;
2681 while queued.len() < batch_max {
2682 match tokio::time::timeout_at(deadline, receiver.recv()).await {
2683 Ok(Some(next)) => queued.push(next),
2684 Ok(None) | Err(_) => break,
2685 }
2686 }
2687
2688 let mut dispatch = Vec::with_capacity(queued.len());
2689 let mut members = Vec::with_capacity(queued.len());
2690 for queued in queued {
2691 members.push(RuntimeBatchMember {
2692 #[cfg(feature = "sql")]
2693 request_id: queued.request_id.clone(),
2694 payload: queued.payload,
2695 operation: queued.operation,
2696 });
2697 dispatch.push((queued.request_id, queued.sender, queued.permit));
2698 }
2699
2700 let Some(runtime) = runtime.upgrade() else {
2701 for (request_id, sender, _permit) in dispatch {
2702 sender.send_replace(Some(WriteOperationResult::DurabilityUnavailable));
2703 write_operations.lock().await.remove(&request_id);
2704 }
2705 break;
2706 };
2707
2708 let blocking_runtime = runtime.clone();
2709 let results =
2710 tokio::task::spawn_blocking(move || blocking_runtime.execute_client_batch(members))
2711 .await
2712 .unwrap_or_else(|error| {
2713 (0..dispatch.len())
2714 .map(|_| {
2715 Err(NodeError::Fatal(format!(
2716 "writer batch task failed: {error}"
2717 )))
2718 })
2719 .collect()
2720 });
2721 let dispatch = dispatch
2722 .into_iter()
2723 .map(|(request_id, sender, permit)| {
2724 drop(permit);
2725 (request_id, sender)
2726 })
2727 .collect::<Vec<_>>();
2728
2729 let committed_index = results
2730 .iter()
2731 .filter_map(|result| result.as_ref().ok().map(ClientWriteResponse::applied_index))
2732 .max();
2733 let durability_available = if let Some(index) = committed_index {
2734 confirm_write_durability(runtime.as_ref(), coordinator.as_deref(), index)
2735 .await
2736 .is_ok()
2737 } else {
2738 true
2739 };
2740
2741 for ((request_id, sender), result) in dispatch.into_iter().zip(results) {
2742 let delivered = if !durability_available && result.is_ok() {
2743 WriteOperationResult::DurabilityUnavailable
2744 } else {
2745 WriteOperationResult::Runtime(result)
2746 };
2747 sender.send_replace(Some(delivered));
2748 write_operations.lock().await.remove(&request_id);
2749 }
2750 }
2751}
2752
2753#[cfg(feature = "sql")]
2754async fn handle_sql_query(
2755 State(state): State<NodeRouteState>,
2756 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2757 request: Result<Json<SqlQueryRequest>, JsonRejection>,
2758) -> Response {
2759 let request = match client_json(request) {
2760 Ok(request) => request,
2761 Err(response) => return response,
2762 };
2763 let runtime = state.runtime;
2764 let consistency = request
2765 .consistency
2766 .unwrap_or(runtime.config.read_consistency());
2767 let max_rows = request.max_rows.unwrap_or(DEFAULT_SQL_MAX_ROWS);
2768 if max_rows == 0 || max_rows > MAX_SQL_MAX_ROWS {
2769 return node_error_response(NodeError::InvalidRequest(format!(
2770 "max_rows must be between 1 and {MAX_SQL_MAX_ROWS}"
2771 )));
2772 }
2773 let result = tokio::task::spawn_blocking(move || {
2774 let _permit = permit;
2775 runtime.query_sql(&request.statement, consistency, max_rows)
2776 })
2777 .await;
2778 match result {
2779 Ok(Ok(response)) => sql_query_http_response(response),
2780 Ok(Err(error)) => node_error_response(error),
2781 Err(error) => client_task_error(error),
2782 }
2783}
2784
2785#[cfg(feature = "sql")]
2786fn sql_query_http_response(response: SqlQueryResponse) -> Response {
2787 match serde_json::to_vec(&response) {
2788 Ok(encoded) if encoded.len() <= MAX_SQL_RESPONSE_BYTES => (
2789 [(axum::http::header::CONTENT_TYPE, "application/json")],
2790 encoded,
2791 )
2792 .into_response(),
2793 Ok(_) => node_error_response(NodeError::InvalidRequest(format!(
2794 "SQL response exceeds {MAX_SQL_RESPONSE_BYTES} bytes"
2795 ))),
2796 Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
2797 }
2798}
2799
2800#[cfg(feature = "graph")]
2801async fn handle_graph_put_document(
2802 State(state): State<NodeRouteState>,
2803 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2804 request: Result<Json<GraphPutDocumentRequest>, JsonRejection>,
2805) -> Response {
2806 let request = match client_json(request) {
2807 Ok(request) => request,
2808 Err(response) => return response,
2809 };
2810 let value = match GraphValueV1::try_from(request.value) {
2811 Ok(value) => value,
2812 Err(error) => return node_error_response(error),
2813 };
2814 let command = match GraphCommandV1::put_document(request.request_id, request.id, value) {
2815 Ok(command) => command,
2816 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2817 };
2818 execute_graph_mutation(state, permit, command).await
2819}
2820
2821#[cfg(feature = "graph")]
2822async fn handle_graph_delete_document(
2823 State(state): State<NodeRouteState>,
2824 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2825 request: Result<Json<GraphDeleteDocumentRequest>, JsonRejection>,
2826) -> Response {
2827 let request = match client_json(request) {
2828 Ok(request) => request,
2829 Err(response) => return response,
2830 };
2831 let command = match GraphCommandV1::delete_document(request.request_id, request.id) {
2832 Ok(command) => command,
2833 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2834 };
2835 execute_graph_mutation(state, permit, command).await
2836}
2837
2838#[cfg(feature = "graph")]
2839async fn execute_graph_mutation(
2840 state: NodeRouteState,
2841 permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2842 command: GraphCommandV1,
2843) -> Response {
2844 let payload = match encode_replicated_graph_command(&command) {
2845 Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
2846 Ok(_) => {
2847 return node_error_response(NodeError::InvalidRequest(format!(
2848 "command exceeds {MAX_COMMAND_BYTES} bytes"
2849 )))
2850 }
2851 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2852 };
2853 coordinate_write(
2854 state,
2855 permit,
2856 command.request_id().to_owned(),
2857 payload,
2858 QueuedOperation::Graph(command),
2859 )
2860 .await
2861}
2862
2863#[cfg(feature = "graph")]
2864async fn handle_graph_get_document(
2865 State(state): State<NodeRouteState>,
2866 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2867 request: Result<Json<GraphGetDocumentRequest>, JsonRejection>,
2868) -> Response {
2869 let request = match client_json(request) {
2870 Ok(request) => request,
2871 Err(response) => return response,
2872 };
2873 let runtime = state.runtime;
2874 let consistency = request
2875 .consistency
2876 .unwrap_or(runtime.config.read_consistency());
2877 let result = tokio::task::spawn_blocking(move || {
2878 let _permit = permit;
2879 runtime.get_graph_document(&request.id, consistency)
2880 })
2881 .await;
2882 match result {
2883 Ok(Ok(response)) => Json(GraphGetDocumentResponse {
2884 value: response.value.map(GraphValueDto::from),
2885 applied_index: response.applied_index,
2886 hash: response.hash,
2887 })
2888 .into_response(),
2889 Ok(Err(error)) => node_error_response(error),
2890 Err(error) => client_task_error(error),
2891 }
2892}
2893
2894#[cfg(feature = "graph")]
2895fn with_graph_client_permit<T>(
2896 permit: Arc<tokio::sync::OwnedSemaphorePermit>,
2897 response_work: impl FnOnce() -> T,
2898) -> T {
2899 let result = response_work();
2900 drop(permit);
2901 result
2902}
2903
2904#[cfg(feature = "graph")]
2905async fn handle_graph_query(
2906 State(state): State<NodeRouteState>,
2907 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2908 request: Result<Json<GraphQueryRequest>, JsonRejection>,
2909) -> Response {
2910 let request = match client_json(request) {
2911 Ok(request) => request,
2912 Err(response) => return response,
2913 };
2914 let parameters = match request
2915 .statement
2916 .parameters
2917 .into_iter()
2918 .map(|(name, value)| GraphParameterValue::try_from(value).map(|value| (name, value)))
2919 .collect::<Result<BTreeMap<_, _>, _>>()
2920 {
2921 Ok(parameters) => parameters,
2922 Err(error) => return node_error_response(error),
2923 };
2924 let runtime = state.runtime;
2925 let consistency = request
2926 .consistency
2927 .unwrap_or(runtime.config.read_consistency());
2928 let max_rows = request.max_rows.unwrap_or(DEFAULT_GRAPH_MAX_ROWS);
2929 if max_rows == 0 || max_rows > MAX_GRAPH_MAX_ROWS {
2930 return node_error_response(NodeError::InvalidRequest(format!(
2931 "max_rows must be between 1 and {MAX_GRAPH_MAX_ROWS}"
2932 )));
2933 }
2934 let result = tokio::task::spawn_blocking(move || {
2935 runtime.query_graph(
2936 &request.statement.cypher,
2937 ¶meters,
2938 consistency,
2939 max_rows,
2940 )
2941 })
2942 .await;
2943 with_graph_client_permit(permit, || match result {
2944 Ok(Ok(result)) => {
2945 let response = GraphQueryResponse::from(result);
2946 match serde_json::to_vec(&response) {
2947 Ok(encoded) if encoded.len() <= MAX_GRAPH_RESPONSE_BYTES => {
2948 Json(response).into_response()
2949 }
2950 Ok(_) => node_error_response(NodeError::InvalidRequest(format!(
2951 "graph response exceeds {MAX_GRAPH_RESPONSE_BYTES} bytes"
2952 ))),
2953 Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
2954 }
2955 }
2956 Ok(Err(error)) => node_error_response(error),
2957 Err(error) => client_task_error(error),
2958 })
2959}
2960
2961#[cfg(feature = "kv")]
2962async fn handle_kv_put(
2963 State(state): State<NodeRouteState>,
2964 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2965 request: Result<Json<KvPutRequest>, JsonRejection>,
2966) -> Response {
2967 let request = match client_json(request) {
2968 Ok(request) => request,
2969 Err(response) => return response,
2970 };
2971 let key = match decode_base64("key", &request.key) {
2972 Ok(value) => value,
2973 Err(error) => return node_error_response(error),
2974 };
2975 let value = match decode_base64("value", &request.value) {
2976 Ok(value) => value,
2977 Err(error) => return node_error_response(error),
2978 };
2979 let command = match KvCommandV1::put(request.request_id, key, value) {
2980 Ok(command) => command,
2981 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
2982 };
2983 execute_kv_mutation(state, permit, command).await
2984}
2985
2986#[cfg(feature = "kv")]
2987async fn handle_kv_delete(
2988 State(state): State<NodeRouteState>,
2989 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
2990 request: Result<Json<KvDeleteRequest>, JsonRejection>,
2991) -> Response {
2992 let request = match client_json(request) {
2993 Ok(request) => request,
2994 Err(response) => return response,
2995 };
2996 let key = match decode_base64("key", &request.key) {
2997 Ok(value) => value,
2998 Err(error) => return node_error_response(error),
2999 };
3000 let command = match KvCommandV1::delete(request.request_id, key) {
3001 Ok(command) => command,
3002 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3003 };
3004 execute_kv_mutation(state, permit, command).await
3005}
3006
3007#[cfg(feature = "kv")]
3008async fn execute_kv_mutation(
3009 state: NodeRouteState,
3010 permit: Arc<tokio::sync::OwnedSemaphorePermit>,
3011 command: KvCommandV1,
3012) -> Response {
3013 let payload = match encode_replicated_kv_command(&command) {
3014 Ok(payload) if payload.len() <= MAX_COMMAND_BYTES => payload,
3015 Ok(_) => {
3016 return node_error_response(NodeError::InvalidRequest(format!(
3017 "command exceeds {MAX_COMMAND_BYTES} bytes"
3018 )))
3019 }
3020 Err(error) => return node_error_response(NodeError::InvalidRequest(error.to_string())),
3021 };
3022 coordinate_write(
3023 state,
3024 permit,
3025 command.request_id().to_owned(),
3026 payload,
3027 QueuedOperation::Kv(command),
3028 )
3029 .await
3030}
3031
3032#[cfg(feature = "kv")]
3033async fn handle_kv_get(
3034 State(state): State<NodeRouteState>,
3035 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3036 request: Result<Json<KvGetRequest>, JsonRejection>,
3037) -> Response {
3038 let request = match client_json(request) {
3039 Ok(request) => request,
3040 Err(response) => return response,
3041 };
3042 let key = match decode_base64("key", &request.key) {
3043 Ok(value) => value,
3044 Err(error) => return node_error_response(error),
3045 };
3046 let runtime = state.runtime;
3047 let consistency = request
3048 .consistency
3049 .unwrap_or(runtime.config.read_consistency());
3050 let result = tokio::task::spawn_blocking(move || {
3051 let _permit = permit;
3052 runtime.get_kv(&key, consistency)
3053 })
3054 .await;
3055 match result {
3056 Ok(Ok(response)) => Json(KvGetResponse {
3057 value: response.value.as_deref().map(encode_base64),
3058 applied_index: response.applied_index,
3059 hash: response.hash,
3060 })
3061 .into_response(),
3062 Ok(Err(error)) => node_error_response(error),
3063 Err(error) => client_task_error(error),
3064 }
3065}
3066
3067#[cfg(feature = "kv")]
3068enum DecodedKvScan {
3069 Range {
3070 start: Vec<u8>,
3071 end: Option<Vec<u8>>,
3072 },
3073 Prefix(Vec<u8>),
3074}
3075
3076#[cfg(feature = "kv")]
3077async fn handle_kv_scan(
3078 State(state): State<NodeRouteState>,
3079 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3080 request: Result<Json<KvScanRequest>, JsonRejection>,
3081) -> Response {
3082 let request = match client_json(request) {
3083 Ok(request) => request,
3084 Err(response) => return response,
3085 };
3086 let limit = request
3087 .limit
3088 .unwrap_or(usize::try_from(DEFAULT_KV_SCAN_LIMIT).expect("u32 fits usize"));
3089 if limit == 0 || limit > MAX_KV_SCAN_ROWS {
3090 return node_error_response(NodeError::InvalidRequest(format!(
3091 "limit must be between 1 and {MAX_KV_SCAN_ROWS}"
3092 )));
3093 }
3094 let cursor = match request.cursor {
3095 Some(cursor) => match decode_base64("cursor", &cursor) {
3096 Ok(cursor) => Some(cursor),
3097 Err(error) => return node_error_response(error),
3098 },
3099 None => None,
3100 };
3101 let scan = match (request.prefix, request.start, request.end) {
3102 (Some(prefix), None, None) => match decode_base64("prefix", &prefix) {
3103 Ok(prefix) => DecodedKvScan::Prefix(prefix),
3104 Err(error) => return node_error_response(error),
3105 },
3106 (None, Some(start), end) => {
3107 let start = match decode_base64("start", &start) {
3108 Ok(start) => start,
3109 Err(error) => return node_error_response(error),
3110 };
3111 let end = match end {
3112 Some(end) => match decode_base64("end", &end) {
3113 Ok(end) => Some(end),
3114 Err(error) => return node_error_response(error),
3115 },
3116 None => None,
3117 };
3118 DecodedKvScan::Range { start, end }
3119 }
3120 _ => {
3121 return node_error_response(NodeError::InvalidRequest(
3122 "provide either prefix alone or start with optional end".into(),
3123 ))
3124 }
3125 };
3126 let runtime = state.runtime;
3127 let consistency = request
3128 .consistency
3129 .unwrap_or(runtime.config.read_consistency());
3130 let result = tokio::task::spawn_blocking(move || {
3131 let _permit = permit;
3132 match scan {
3133 DecodedKvScan::Range { start, end } => runtime.scan_kv_range(
3134 &start,
3135 end.as_deref(),
3136 limit,
3137 cursor.as_deref(),
3138 consistency,
3139 ),
3140 DecodedKvScan::Prefix(prefix) => {
3141 runtime.scan_kv_prefix(&prefix, limit, cursor.as_deref(), consistency)
3142 }
3143 }
3144 })
3145 .await;
3146 match result {
3147 Ok(Ok(result)) => {
3148 let response = KvScanResponse {
3149 entries: result
3150 .rows()
3151 .iter()
3152 .map(|row| KvScanEntryDto {
3153 key: encode_base64(row.key()),
3154 value: encode_base64(row.value()),
3155 })
3156 .collect(),
3157 next_cursor: result.next_cursor().map(encode_base64),
3158 applied_index: result.tip().applied_index(),
3159 hash: result.tip().applied_hash(),
3160 };
3161 match serde_json::to_vec(&response) {
3162 Ok(encoded) if encoded.len() <= MAX_KV_SCAN_RESPONSE_BYTES => {
3163 Json(response).into_response()
3164 }
3165 Ok(_) => node_error_response(NodeError::ResourceExhausted(format!(
3166 "KV scan response exceeds {MAX_KV_SCAN_RESPONSE_BYTES} bytes"
3167 ))),
3168 Err(error) => node_error_response(NodeError::InvalidRequest(error.to_string())),
3169 }
3170 }
3171 Ok(Err(error)) => node_error_response(error),
3172 Err(error) => client_task_error(error),
3173 }
3174}
3175
3176#[cfg(feature = "sql")]
3177async fn handle_read(
3178 State(state): State<NodeRouteState>,
3179 Extension(permit): Extension<Arc<tokio::sync::OwnedSemaphorePermit>>,
3180 request: Result<Json<ReadRequest>, JsonRejection>,
3181) -> Response {
3182 let request = match client_json(request) {
3183 Ok(request) => request,
3184 Err(response) => return response,
3185 };
3186 let runtime = state.runtime;
3187 let consistency = request
3188 .consistency
3189 .unwrap_or(runtime.config.read_consistency());
3190 let result = tokio::task::spawn_blocking(move || {
3191 let _permit = permit;
3192 runtime.read(&request.key, consistency)
3193 })
3194 .await;
3195 match result {
3196 Ok(Ok(response)) => Json(response).into_response(),
3197 Ok(Err(error)) => node_error_response(error),
3198 Err(error) => client_task_error(error),
3199 }
3200}
3201
3202async fn peer_gate(
3203 State(state): State<PeerGateState>,
3204 mut request: Request,
3205 next: Next,
3206) -> Response {
3207 if !recovery_generation_matches(request.headers(), state.recovery_generation) {
3208 return StatusCode::UNAUTHORIZED.into_response();
3209 }
3210 let Some(authenticated_peer) =
3211 authenticated_peer(request.headers(), &state.peers, state.protocol_version)
3212 else {
3213 return StatusCode::UNAUTHORIZED.into_response();
3214 };
3215 let permit = match state.slots.try_acquire_owned() {
3216 Ok(permit) => Arc::new(permit),
3217 Err(_) => return StatusCode::TOO_MANY_REQUESTS.into_response(),
3218 };
3219 request.extensions_mut().insert(permit);
3220 request
3221 .extensions_mut()
3222 .insert(AuthenticatedPeer(authenticated_peer));
3223 next.run(request).await
3224}
3225
3226async fn client_gate(
3227 State(state): State<ClientGateState>,
3228 mut request: Request,
3229 next: Next,
3230) -> Response {
3231 if !client_authenticated(request.headers(), state.runtime.config.client_token()) {
3232 return client_error_response(
3233 StatusCode::UNAUTHORIZED,
3234 "unauthorized",
3235 false,
3236 "client authentication failed",
3237 None,
3238 );
3239 }
3240 if let Some(response) = runtime_readiness_response(state.runtime.as_ref()) {
3241 return response;
3242 }
3243 if client_write_path(request.uri().path())
3244 && state
3245 .coordinator
3246 .as_ref()
3247 .is_some_and(|coordinator| coordinator.write_allowed().is_err())
3248 {
3249 return client_error_response(
3250 StatusCode::SERVICE_UNAVAILABLE,
3251 "writes_unavailable",
3252 true,
3253 "writes are temporarily unavailable",
3254 None,
3255 );
3256 }
3257 let permit = match state.slots.try_acquire_owned() {
3258 Ok(permit) => Arc::new(permit),
3259 Err(_) => {
3260 return client_error_response(
3261 StatusCode::TOO_MANY_REQUESTS,
3262 "overloaded",
3263 true,
3264 "client request capacity is exhausted",
3265 None,
3266 )
3267 }
3268 };
3269 request.extensions_mut().insert(permit);
3270 next.run(request).await
3271}
3272
3273fn client_write_path(path: &str) -> bool {
3274 #[cfg(feature = "sql")]
3275 if matches!(path, WRITE_PATH | SQL_EXECUTE_PATH) {
3276 return true;
3277 }
3278 #[cfg(feature = "graph")]
3279 if matches!(path, GRAPH_PUT_DOCUMENT_PATH | GRAPH_DELETE_DOCUMENT_PATH) {
3280 return true;
3281 }
3282 #[cfg(feature = "kv")]
3283 if matches!(path, KV_PUT_PATH | KV_DELETE_PATH) {
3284 return true;
3285 }
3286 false
3287}
3288
3289async fn handle_livez() -> StatusCode {
3290 StatusCode::OK
3291}
3292
3293async fn handle_readyz(State(state): State<NodeRouteState>) -> StatusCode {
3294 if state.runtime.is_ready()
3295 && state
3296 .coordinator
3297 .as_ref()
3298 .is_none_or(|coordinator| coordinator.health() == DurabilityHealth::Available)
3299 {
3300 StatusCode::OK
3301 } else {
3302 StatusCode::SERVICE_UNAVAILABLE
3303 }
3304}
3305
3306fn next_sync_flush_retry(current: Duration) -> Duration {
3307 current.saturating_mul(2).min(SYNC_FLUSH_RETRY_MAX)
3308}
3309
3310pub async fn confirm_write_durability(
3315 runtime: &NodeRuntime,
3316 coordinator: Option<&CheckpointCoordinator>,
3317 index: LogIndex,
3318) -> Result<(), DurabilityError> {
3319 let Some(coordinator) = coordinator else {
3320 return Ok(());
3321 };
3322 coordinator.note_committed(index);
3323 if !matches!(coordinator.mode(), DurabilityMode::Sync) {
3324 return Ok(());
3325 }
3326
3327 let mut retry_delay = SYNC_FLUSH_RETRY_INITIAL;
3328 loop {
3329 if runtime.operation_cancelled.load(Ordering::Acquire) {
3330 return Err(DurabilityError::Unavailable);
3331 }
3332 match coordinator.flush_runtime(runtime, index).await {
3333 Ok(tip) if tip.index() >= index => return Ok(()),
3334 Ok(tip) => {
3335 return Err(DurabilityError::LocalLogGap {
3336 expected: index,
3337 actual: Some(tip.index()),
3338 })
3339 }
3340 Err(DurabilityError::Archive(_) | DurabilityError::Io(_)) => {
3341 let cancelled = runtime.operation_cancelled_notify.notified();
3342 tokio::pin!(cancelled);
3343 cancelled.as_mut().enable();
3344 if runtime.operation_cancelled.load(Ordering::Acquire) {
3345 return Err(DurabilityError::Unavailable);
3346 }
3347 tokio::select! {
3348 () = tokio::time::sleep(retry_delay) => {}
3349 () = &mut cancelled => return Err(DurabilityError::Unavailable),
3350 }
3351 retry_delay = next_sync_flush_retry(retry_delay);
3352 }
3353 Err(error) => return Err(error),
3354 }
3355 }
3356}
3357
3358fn authenticated_peer(
3359 headers: &HeaderMap,
3360 peers: &[PeerConfig],
3361 protocol_version: &str,
3362) -> Option<String> {
3363 if header_text(headers, VERSION_HEADER) != Some(protocol_version) {
3364 return None;
3365 }
3366 let node_id = header_text(headers, NODE_ID_HEADER)?;
3367 let token = bearer_token(headers)?;
3368 peer_credentials_authenticated(node_id, token, peers).then(|| node_id.to_owned())
3369}
3370
3371fn peer_credentials_authenticated(node_id: &str, token: &str, peers: &[PeerConfig]) -> bool {
3372 peers
3373 .iter()
3374 .find(|peer| peer.node_id == node_id)
3375 .is_some_and(|peer| secrets_equal(peer.token.as_bytes(), token.as_bytes()))
3376}
3377
3378fn recovery_generation_matches(headers: &HeaderMap, expected: u64) -> bool {
3379 let expected = expected.to_string();
3380 header_text(headers, RECOVERY_GENERATION_HEADER) == Some(expected.as_str())
3381}
3382
3383fn client_authenticated(headers: &HeaderMap, expected_token: &str) -> bool {
3384 !expected_token.is_empty()
3385 && version_matches(headers)
3386 && bearer_token(headers)
3387 .is_some_and(|token| secrets_equal(expected_token.as_bytes(), token.as_bytes()))
3388}
3389
3390fn version_matches(headers: &HeaderMap) -> bool {
3391 header_text(headers, VERSION_HEADER) == Some(PROTOCOL_VERSION)
3392}
3393
3394fn header_text<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
3395 headers.get(name)?.to_str().ok()
3396}
3397
3398fn bearer_token(headers: &HeaderMap) -> Option<&str> {
3399 header_text(headers, "authorization")?.strip_prefix("Bearer ")
3400}
3401
3402fn secrets_equal(left: &[u8], right: &[u8]) -> bool {
3403 if left.len() != right.len() {
3404 return false;
3405 }
3406 left.iter()
3407 .zip(right)
3408 .fold(0_u8, |difference, (left, right)| {
3409 difference | (left ^ right)
3410 })
3411 == 0
3412}
3413
3414fn node_error_response(error: NodeError) -> Response {
3415 let (status, statement_index) = match &error {
3416 NodeError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, None),
3417 #[cfg(feature = "sql")]
3418 NodeError::InvalidSqlStatement {
3419 statement_index, ..
3420 } => (StatusCode::BAD_REQUEST, Some(*statement_index)),
3421 #[cfg(feature = "sql")]
3422 NodeError::RequestConflict(_) => (StatusCode::CONFLICT, None),
3423 NodeError::PreconditionFailed(_) => (StatusCode::CONFLICT, None),
3424 NodeError::SnapshotRequired(_)
3425 | NodeError::Unavailable(_)
3426 | NodeError::ResourceExhausted(_)
3427 | NodeError::ConfigurationTransition { .. }
3428 | NodeError::Contention(_)
3429 | NodeError::WinnerLimitExceeded => (StatusCode::SERVICE_UNAVAILABLE, None),
3430 NodeError::DataRootLocked(_)
3431 | NodeError::UnsupportedAckMode(_)
3432 | NodeError::ExecutionProfileMismatch { .. }
3433 | NodeError::Storage(_)
3434 | NodeError::Reconciliation(_)
3435 | NodeError::Invariant(_)
3436 | NodeError::Fatal(_) => (StatusCode::INTERNAL_SERVER_ERROR, None),
3437 };
3438 let classification = error.classification();
3439 if !matches!(&error, NodeError::Fatal(_)) {
3440 eprintln!(
3441 "node request failed (code={}, retryable={}): {}",
3442 classification.code(),
3443 classification.retryable(),
3444 escaped_error_detail(&error)
3445 );
3446 }
3447 client_error_response(
3448 status,
3449 classification.code(),
3450 classification.retryable(),
3451 node_error_message(status),
3452 statement_index,
3453 )
3454}
3455
3456fn node_error_message(status: StatusCode) -> &'static str {
3457 match status {
3458 StatusCode::BAD_REQUEST => "request could not be processed",
3459 StatusCode::CONFLICT => "request conflicts with current state",
3460 StatusCode::SERVICE_UNAVAILABLE => "service is temporarily unavailable",
3461 _ => "internal server error",
3462 }
3463}
3464
3465const MAX_ESCAPED_ERROR_DETAIL_BYTES: usize = 4 * 1024;
3466const ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER: &str = "...[truncated]";
3467
3468fn escaped_error_detail(error: &dyn fmt::Display) -> String {
3469 let detail = error.to_string();
3470 let mut escaped = String::with_capacity(detail.len().min(MAX_ESCAPED_ERROR_DETAIL_BYTES));
3471 for character in detail.chars() {
3472 let character_start = escaped.len();
3473 for escaped_character in character.escape_default() {
3474 if escaped.len()
3475 + escaped_character.len_utf8()
3476 + ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER.len()
3477 > MAX_ESCAPED_ERROR_DETAIL_BYTES
3478 {
3479 escaped.truncate(character_start);
3480 escaped.push_str(ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER);
3481 return escaped;
3482 }
3483 escaped.push(escaped_character);
3484 }
3485 }
3486 escaped
3487}
3488
3489#[allow(clippy::result_large_err)]
3490fn client_json<T>(request: Result<Json<T>, JsonRejection>) -> Result<T, Response> {
3491 request.map(|Json(request)| request).map_err(|rejection| {
3492 let status = rejection.status();
3493 eprintln!("invalid JSON request: {}", escaped_error_detail(&rejection));
3494 client_json_error_response(status)
3495 })
3496}
3497
3498fn client_json_error_response(status: StatusCode) -> Response {
3499 let code = if status == StatusCode::PAYLOAD_TOO_LARGE {
3500 "payload_too_large"
3501 } else {
3502 "invalid_json"
3503 };
3504 client_error_response(status, code, false, "request body is invalid", None)
3505}
3506
3507fn client_task_error(error: tokio::task::JoinError) -> Response {
3508 eprintln!("request task failed: {}", escaped_error_detail(&error));
3509 client_error_response(
3510 StatusCode::INTERNAL_SERVER_ERROR,
3511 "task_failed",
3512 false,
3513 "request task failed",
3514 None,
3515 )
3516}
3517
3518fn client_error_response(
3519 status: StatusCode,
3520 code: impl Into<String>,
3521 retryable: bool,
3522 message: impl Into<String>,
3523 statement_index: Option<usize>,
3524) -> Response {
3525 (
3526 status,
3527 Json(ClientErrorResponse {
3528 code: code.into(),
3529 retryable,
3530 message: message.into(),
3531 statement_index,
3532 }),
3533 )
3534 .into_response()
3535}
3536
3537pub fn install_successor_recorder(
3538 recorder: &RecorderFileStore,
3539 next_config_id: u64,
3540 membership: Membership,
3541 stop: &StopInformation,
3542) -> Result<rhiza_quepaxa::ConfigurationState, NodeError> {
3543 if stop.version != 2 || stop.entry.config_id.checked_add(1) != Some(next_config_id) {
3544 return Err(NodeError::PreconditionFailed(
3545 "successor identity does not match the Stop proof".into(),
3546 ));
3547 }
3548 recorder
3549 .install_successor_from_proof(membership, &stop.proof)
3550 .map_err(|error| NodeError::Reconciliation(error.to_string()))
3551}
3552
3553pub fn recover_successor_recorder_after_checkpoint(
3554 recorder: &RecorderFileStore,
3555 config: &NodeConfig,
3556 next_config_id: u64,
3557 membership: Membership,
3558 stop: &StopInformation,
3559) -> Result<rhiza_quepaxa::ConfigurationState, NodeError> {
3560 let installed = install_successor_recorder(recorder, next_config_id, membership.clone(), stop)?;
3561 let log = FileLogStore::open_with_configuration(
3562 config.data_dir.join("consensus/log"),
3563 &config.cluster_id,
3564 config.epoch,
3565 config.log_initial_configuration.clone(),
3566 )
3567 .map_err(|error| NodeError::Storage(error.to_string()))?;
3568 let recovered_configuration = log
3569 .configuration_state()
3570 .map_err(|error| NodeError::Storage(error.to_string()))?;
3571 if !recovered_configuration.is_active() {
3572 return Ok(installed);
3573 }
3574 if recovered_configuration.config_id() != next_config_id
3575 || recovered_configuration.digest() != membership.digest()
3576 {
3577 return Err(NodeError::Reconciliation(
3578 "recovered successor qlog configuration does not match the target bundle".into(),
3579 ));
3580 }
3581 let tip = log
3582 .logical_state()
3583 .map_err(|error| NodeError::Storage(error.to_string()))?
3584 .tip
3585 .ok_or_else(|| NodeError::Reconciliation("recovered successor qlog is empty".into()))?;
3586 recorder
3587 .recover_successor_activation_from_checkpoint(
3588 stop.entry.index,
3589 stop.entry.hash,
3590 tip.index(),
3591 tip.hash(),
3592 )
3593 .map_err(|error| NodeError::Reconciliation(error.to_string()))
3594}
3595
3596fn recorder_error_status(error: &rhiza_quepaxa::Error) -> StatusCode {
3597 match error {
3598 rhiza_quepaxa::Error::NoQuorum
3599 | rhiza_quepaxa::Error::CommandUnavailable
3600 | rhiza_quepaxa::Error::Io(_)
3601 | rhiza_quepaxa::Error::RecorderRootLocked(_) => StatusCode::SERVICE_UNAVAILABLE,
3602 rhiza_quepaxa::Error::Rejected(_) => StatusCode::CONFLICT,
3603 _ => StatusCode::INTERNAL_SERVER_ERROR,
3604 }
3605}
3606
3607fn fetch_log_error_status(error: &FetchLogError) -> StatusCode {
3608 match error {
3609 FetchLogError::InvalidRequest { .. } => StatusCode::BAD_REQUEST,
3610 FetchLogError::SnapshotRequired { .. } | FetchLogError::Gap { .. } => StatusCode::CONFLICT,
3611 FetchLogError::Decode { .. } | FetchLogError::Transport { .. } => {
3612 StatusCode::SERVICE_UNAVAILABLE
3613 }
3614 FetchLogError::InvalidAnchor { .. }
3615 | FetchLogError::InvalidEntry { .. }
3616 | FetchLogError::ForeignIdentity { .. } => StatusCode::INTERNAL_SERVER_ERROR,
3617 }
3618}
3619
3620fn runtime_readiness_response(runtime: &NodeRuntime) -> Option<Response> {
3621 if runtime.is_fatal() {
3622 Some(client_error_response(
3623 StatusCode::INTERNAL_SERVER_ERROR,
3624 "fatal",
3625 false,
3626 "node is fatally unavailable",
3627 None,
3628 ))
3629 } else if !runtime.is_ready() {
3630 Some(client_error_response(
3631 StatusCode::SERVICE_UNAVAILABLE,
3632 "unavailable",
3633 true,
3634 "node is not ready",
3635 None,
3636 ))
3637 } else {
3638 None
3639 }
3640}
3641
3642#[derive(Debug, serde::Deserialize, serde::Serialize)]
3643#[serde(tag = "status", content = "body")]
3644enum FetchLogHttpResponse {
3645 Fetched(FetchLogResponse),
3646 Failed(FetchLogError),
3647}
3648
3649pub fn catch_up_missing_entries<P: LogPeer + ?Sized>(
3650 local_last_index: LogIndex,
3651 local_last_hash: LogHash,
3652 cluster_id: &str,
3653 epoch: u64,
3654 config_id: u64,
3655 peer: &P,
3656 max_entries: u32,
3657) -> Result<Vec<LogEntry>, FetchLogError> {
3658 if max_entries == 0 {
3659 return Ok(Vec::new());
3660 }
3661 if max_entries > MAX_FETCH_ENTRIES {
3662 return Err(FetchLogError::InvalidRequest {
3663 message: format!("max_entries exceeds {MAX_FETCH_ENTRIES}"),
3664 });
3665 }
3666 if cluster_id.is_empty() {
3667 return Err(FetchLogError::InvalidRequest {
3668 message: "cluster_id must not be empty".into(),
3669 });
3670 }
3671 let from_index =
3672 local_last_index
3673 .checked_add(1)
3674 .ok_or_else(|| FetchLogError::InvalidRequest {
3675 message: "local qlog index is exhausted".into(),
3676 })?;
3677 let response = peer.fetch_log(FetchLogRequest {
3678 from_index,
3679 max_entries,
3680 })?;
3681 if response.entries.len() > max_entries as usize {
3682 return Err(FetchLogError::InvalidRequest {
3683 message: "peer returned more entries than requested".into(),
3684 });
3685 }
3686 if response.last_index < local_last_index {
3687 return Err(FetchLogError::Gap {
3688 expected: local_last_index,
3689 actual: Some(response.last_index),
3690 });
3691 }
3692 if response.entries.is_empty() && response.last_index >= from_index {
3693 return Err(FetchLogError::Gap {
3694 expected: from_index,
3695 actual: None,
3696 });
3697 }
3698 validate_fetched_entries(
3699 from_index,
3700 local_last_hash,
3701 cluster_id,
3702 epoch,
3703 config_id,
3704 response.entries,
3705 )
3706}
3707
3708fn validate_fetched_entries(
3709 from_index: LogIndex,
3710 local_last_hash: LogHash,
3711 cluster_id: &str,
3712 epoch: u64,
3713 config_id: u64,
3714 entries: Vec<LogEntry>,
3715) -> Result<Vec<LogEntry>, FetchLogError> {
3716 validate_fetched_entries_with_configuration(
3717 from_index,
3718 local_last_hash,
3719 cluster_id,
3720 epoch,
3721 ConfigurationState::active(config_id, LogHash::ZERO),
3722 entries,
3723 )
3724}
3725
3726fn validate_fetched_entries_with_configuration(
3727 from_index: LogIndex,
3728 local_last_hash: LogHash,
3729 cluster_id: &str,
3730 epoch: u64,
3731 mut configuration_state: ConfigurationState,
3732 entries: Vec<LogEntry>,
3733) -> Result<Vec<LogEntry>, FetchLogError> {
3734 let mut expected = from_index;
3735 let mut previous_hash = local_last_hash;
3736 for entry in &entries {
3737 if entry.index != expected {
3738 return Err(FetchLogError::Gap {
3739 expected,
3740 actual: Some(entry.index),
3741 });
3742 }
3743 if entry.cluster_id != cluster_id || entry.epoch != epoch {
3744 return Err(FetchLogError::ForeignIdentity { index: entry.index });
3745 }
3746 if entry.prev_hash != previous_hash {
3747 return Err(FetchLogError::InvalidAnchor {
3748 expected: previous_hash,
3749 actual: entry.prev_hash,
3750 });
3751 }
3752 if entry.recompute_hash() != entry.hash {
3753 return Err(FetchLogError::InvalidEntry {
3754 index: entry.index,
3755 message: "hash does not match entry contents".into(),
3756 });
3757 }
3758 validate_entry_shape(entry).map_err(|message| FetchLogError::InvalidEntry {
3759 index: entry.index,
3760 message,
3761 })?;
3762 configuration_state = configuration_state.validate_entry(entry).map_err(|error| {
3763 FetchLogError::InvalidEntry {
3764 index: entry.index,
3765 message: error.to_string(),
3766 }
3767 })?;
3768 expected = expected
3769 .checked_add(1)
3770 .ok_or_else(|| FetchLogError::InvalidEntry {
3771 index: entry.index,
3772 message: "qlog index is exhausted".into(),
3773 })?;
3774 previous_hash = entry.hash;
3775 }
3776 Ok(entries)
3777}
3778
3779#[derive(Clone, Eq, PartialEq)]
3780pub struct PeerConfig {
3781 node_id: String,
3782 base_url: String,
3783 log_base_url: String,
3784 token: String,
3785}
3786
3787impl fmt::Debug for PeerConfig {
3788 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3789 f.debug_struct("PeerConfig")
3790 .field("node_id", &self.node_id)
3791 .field("base_url", &self.base_url)
3792 .field("log_base_url", &self.log_base_url)
3793 .field("token", &"[redacted]")
3794 .finish()
3795 }
3796}
3797
3798impl PeerConfig {
3799 pub fn new(
3800 node_id: impl Into<String>,
3801 base_url: impl Into<String>,
3802 token: impl Into<String>,
3803 ) -> Result<Self, ConfigError> {
3804 let base_url = base_url.into();
3805 Self::new_with_log_url(node_id, base_url.clone(), base_url, token)
3806 }
3807
3808 pub fn new_with_log_url(
3809 node_id: impl Into<String>,
3810 base_url: impl Into<String>,
3811 log_base_url: impl Into<String>,
3812 token: impl Into<String>,
3813 ) -> Result<Self, ConfigError> {
3814 let node_id = node_id.into();
3815 if !valid_nonblank_header_value(&node_id) {
3816 return Err(ConfigError::EmptyPeerNodeId);
3817 }
3818 let base_url = validate_peer_base_url(base_url.into())?;
3819 let log_base_url = validate_peer_base_url(log_base_url.into())?;
3820 let token = token.into();
3821 if !valid_auth_token(&token) {
3822 return Err(ConfigError::EmptyPeerToken);
3823 }
3824 Ok(Self {
3825 node_id,
3826 base_url,
3827 log_base_url,
3828 token,
3829 })
3830 }
3831
3832 pub fn node_id(&self) -> &str {
3833 &self.node_id
3834 }
3835
3836 pub fn base_url(&self) -> &str {
3837 &self.base_url
3838 }
3839
3840 pub fn log_base_url(&self) -> &str {
3841 &self.log_base_url
3842 }
3843
3844 pub fn token(&self) -> &str {
3845 &self.token
3846 }
3847}
3848
3849fn validate_peer_base_url(url: String) -> Result<String, ConfigError> {
3850 let url = url.trim_end_matches('/').to_string();
3851 if url.trim().is_empty() {
3852 return Err(ConfigError::EmptyPeerBaseUrl);
3853 }
3854 let parsed =
3855 reqwest::Url::parse(&url).map_err(|_| ConfigError::InvalidPeerBaseUrl(url.clone()))?;
3856 if !matches!(parsed.scheme(), "http" | "https")
3857 || parsed.host_str().is_none()
3858 || !parsed.username().is_empty()
3859 || parsed.password().is_some()
3860 || parsed.path() != "/"
3861 || parsed.query().is_some()
3862 || parsed.fragment().is_some()
3863 {
3864 return Err(ConfigError::InvalidPeerBaseUrl(url));
3865 }
3866 Ok(url)
3867}
3868
3869pub(crate) fn valid_nonblank_header_value(value: &str) -> bool {
3870 !value.trim().is_empty()
3871 && axum::http::HeaderValue::try_from(value).is_ok_and(|value| value.to_str().is_ok())
3872}
3873
3874pub(crate) fn valid_auth_token(value: &str) -> bool {
3875 valid_nonblank_header_value(value) && !value.chars().any(char::is_whitespace)
3876}
3877
3878#[derive(Clone, Eq, PartialEq)]
3879pub struct NodeConfig {
3880 cluster_id_source: String,
3881 logical_cluster_id: String,
3882 cluster_id: String,
3883 node_id: String,
3884 data_dir: PathBuf,
3885 epoch: u64,
3886 membership: Membership,
3887 log_initial_configuration: ConfigurationState,
3888 configuration_state: ConfigurationState,
3889 predecessor_stop_entry: Option<LogEntry>,
3890 recovery_generation: u64,
3891 peers: Vec<PeerConfig>,
3892 client_token: String,
3893 read_consistency: ReadConsistency,
3894 ack_mode: AckMode,
3895 writer_batch_max: usize,
3896 writer_batch_window: Duration,
3897 execution_profile: ExecutionProfile,
3898 #[cfg(feature = "sql")]
3899 sql_write_profiler: Option<SqlWriteProfiler>,
3900 #[cfg(feature = "sql")]
3901 sql_group_commit_queue_capacity: usize,
3902}
3903
3904impl fmt::Debug for NodeConfig {
3905 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3906 let mut debug = f.debug_struct("NodeConfig");
3907 debug
3908 .field("cluster_id_source", &self.cluster_id_source)
3909 .field("logical_cluster_id", &self.logical_cluster_id)
3910 .field("cluster_id", &self.cluster_id)
3911 .field("node_id", &self.node_id)
3912 .field("data_dir", &self.data_dir)
3913 .field("epoch", &self.epoch)
3914 .field("membership", &self.membership.members())
3915 .field("log_initial_configuration", &self.log_initial_configuration)
3916 .field("configuration_state", &self.configuration_state)
3917 .field(
3918 "predecessor_stop_entry",
3919 &self
3920 .predecessor_stop_entry
3921 .as_ref()
3922 .map(|entry| (entry.index, entry.hash)),
3923 )
3924 .field("recovery_generation", &self.recovery_generation)
3925 .field("peers", &self.peers)
3926 .field("client_token", &"[redacted]")
3927 .field("read_consistency", &self.read_consistency)
3928 .field("ack_mode", &self.ack_mode)
3929 .field("writer_batch_max", &self.writer_batch_max)
3930 .field("writer_batch_window", &self.writer_batch_window)
3931 .field("execution_profile", &self.execution_profile);
3932 #[cfg(feature = "sql")]
3933 debug.field(
3934 "sql_write_profiler",
3935 &self.sql_write_profiler.as_ref().map(|_| "installed"),
3936 );
3937 #[cfg(feature = "sql")]
3938 debug.field(
3939 "sql_group_commit_queue_capacity",
3940 &self.sql_group_commit_queue_capacity,
3941 );
3942 debug.finish()
3943 }
3944}
3945
3946impl NodeConfig {
3947 pub fn new<P>(
3948 cluster_id: impl Into<String>,
3949 node_id: impl Into<String>,
3950 data_dir: PathBuf,
3951 epoch: u64,
3952 config_id: u64,
3953 peers: P,
3954 client_token: impl Into<String>,
3955 ) -> Result<Self, ConfigError>
3956 where
3957 P: Into<Vec<PeerConfig>>,
3958 {
3959 let peers = peers.into();
3960 let membership = membership_from_peers(&peers)?;
3961 let configuration_state = ConfigurationState::active(config_id, membership.digest());
3962 Self::new_with_configuration(
3963 cluster_id,
3964 node_id,
3965 data_dir,
3966 epoch,
3967 membership,
3968 configuration_state,
3969 peers,
3970 client_token,
3971 )
3972 }
3973
3974 pub fn new_embedded<I, S>(
3975 cluster_id: impl Into<String>,
3976 node_id: impl Into<String>,
3977 data_dir: PathBuf,
3978 epoch: u64,
3979 config_id: u64,
3980 members: I,
3981 ) -> Result<Self, ConfigError>
3982 where
3983 I: IntoIterator<Item = S>,
3984 S: Into<String>,
3985 {
3986 let cluster_id = cluster_id.into();
3987 let node_id = node_id.into();
3988 validate_node_identity(&cluster_id, &node_id, &data_dir, epoch, config_id)?;
3989 let membership = membership_from_node_ids(members.into_iter().map(Into::into).collect())?;
3990 if !membership.contains(&node_id) {
3991 return Err(ConfigError::LocalNodeMissing);
3992 }
3993 let configuration_state = ConfigurationState::active(config_id, membership.digest());
3994 Self::from_validated_parts(
3995 cluster_id,
3996 node_id,
3997 data_dir,
3998 epoch,
3999 membership,
4000 configuration_state,
4001 Vec::new(),
4002 String::new(),
4003 )
4004 }
4005
4006 #[allow(clippy::too_many_arguments)]
4007 pub fn new_with_configuration<P>(
4008 cluster_id: impl Into<String>,
4009 node_id: impl Into<String>,
4010 data_dir: PathBuf,
4011 epoch: u64,
4012 membership: Membership,
4013 configuration_state: ConfigurationState,
4014 peers: P,
4015 client_token: impl Into<String>,
4016 ) -> Result<Self, ConfigError>
4017 where
4018 P: Into<Vec<PeerConfig>>,
4019 {
4020 let cluster_id = cluster_id.into();
4021 let node_id = node_id.into();
4022 let client_token = client_token.into();
4023 let peers = peers.into();
4024
4025 validate_node_identity(
4026 &cluster_id,
4027 &node_id,
4028 &data_dir,
4029 epoch,
4030 configuration_state.config_id(),
4031 )?;
4032 if !(3..=7).contains(&peers.len()) {
4033 return Err(ConfigError::InvalidPeerCount(peers.len()));
4034 }
4035 let mut peer_ids = HashSet::with_capacity(peers.len());
4036 let mut peer_tokens = HashSet::with_capacity(peers.len());
4037 for peer in &peers {
4038 if !peer_ids.insert(peer.node_id.clone()) {
4039 return Err(ConfigError::DuplicatePeerNodeId(peer.node_id.clone()));
4040 }
4041 if !peer_tokens.insert(peer.token.as_str()) {
4042 return Err(ConfigError::DuplicatePeerToken);
4043 }
4044 }
4045 if !peer_ids.contains(&node_id) {
4046 return Err(ConfigError::LocalNodeMissing);
4047 }
4048 if peer_ids.len() != membership.members().len()
4049 || membership
4050 .members()
4051 .iter()
4052 .any(|member| !peer_ids.contains(member))
4053 {
4054 return Err(ConfigError::PeerMembershipMismatch);
4055 }
4056 if configuration_state.is_active() && configuration_state.digest() != membership.digest() {
4057 return Err(ConfigError::PeerMembershipMismatch);
4058 }
4059 if !valid_auth_token(&client_token) {
4060 return Err(ConfigError::EmptyClientToken);
4061 }
4062 if peer_tokens.contains(client_token.as_str()) {
4063 return Err(ConfigError::ClientTokenConflictsWithPeer);
4064 }
4065
4066 Self::from_validated_parts(
4067 cluster_id,
4068 node_id,
4069 data_dir,
4070 epoch,
4071 membership,
4072 configuration_state,
4073 peers,
4074 client_token,
4075 )
4076 }
4077
4078 #[allow(clippy::too_many_arguments)]
4079 fn from_validated_parts(
4080 cluster_id: String,
4081 node_id: String,
4082 data_dir: PathBuf,
4083 epoch: u64,
4084 membership: Membership,
4085 configuration_state: ConfigurationState,
4086 peers: Vec<PeerConfig>,
4087 client_token: String,
4088 ) -> Result<Self, ConfigError> {
4089 let log_initial_configuration = ConfigurationState::active(
4090 configuration_state.config_id(),
4091 configuration_state.digest(),
4092 );
4093 let execution_profile =
4094 canonical_cluster_profile(&cluster_id).unwrap_or(ExecutionProfile::Sqlite);
4095 let logical_cluster_id = ["rhiza:sql:", "rhiza:graph:", "rhiza:kv:"]
4096 .into_iter()
4097 .find_map(|prefix| cluster_id.strip_prefix(prefix))
4098 .unwrap_or(&cluster_id)
4099 .to_owned();
4100 let effective_cluster_id = effective_cluster_id(execution_profile, &cluster_id)?;
4101 Ok(Self {
4102 cluster_id_source: cluster_id,
4103 cluster_id: effective_cluster_id,
4104 logical_cluster_id,
4105 node_id,
4106 data_dir,
4107 epoch,
4108 membership,
4109 log_initial_configuration,
4110 configuration_state,
4111 predecessor_stop_entry: None,
4112 recovery_generation: 1,
4113 peers,
4114 client_token,
4115 read_consistency: ReadConsistency::ReadBarrier,
4116 ack_mode: AckMode::HaFirst,
4117 writer_batch_max: DEFAULT_WRITER_BATCH_MAX,
4118 writer_batch_window: DEFAULT_WRITER_BATCH_WINDOW,
4119 execution_profile,
4120 #[cfg(feature = "sql")]
4121 sql_write_profiler: None,
4122 #[cfg(feature = "sql")]
4123 sql_group_commit_queue_capacity: DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY,
4124 })
4125 }
4126
4127 pub fn with_execution_profile(
4128 mut self,
4129 execution_profile: ExecutionProfile,
4130 ) -> Result<Self, ConfigError> {
4131 self.cluster_id = effective_cluster_id(execution_profile, &self.cluster_id_source)?;
4132 self.execution_profile = execution_profile;
4133 Ok(self)
4134 }
4135
4136 pub fn with_read_consistency(mut self, read_consistency: ReadConsistency) -> Self {
4137 self.read_consistency = read_consistency;
4138 self
4139 }
4140
4141 #[cfg(feature = "sql")]
4142 pub fn with_sql_write_profiler(mut self, profiler: SqlWriteProfiler) -> Self {
4143 self.sql_write_profiler = Some(profiler);
4144 self
4145 }
4146
4147 #[cfg(feature = "sql")]
4148 pub fn with_sql_group_commit_queue_capacity(
4149 mut self,
4150 capacity: usize,
4151 ) -> Result<Self, ConfigError> {
4152 if !(1..=MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY).contains(&capacity) {
4153 return Err(ConfigError::InvalidSqlGroupCommitQueueCapacity(capacity));
4154 }
4155 self.sql_group_commit_queue_capacity = capacity;
4156 Ok(self)
4157 }
4158
4159 pub fn with_ack_mode(mut self, ack_mode: AckMode) -> Self {
4160 self.ack_mode = ack_mode;
4161 self
4162 }
4163
4164 pub fn with_writer_batching(
4165 mut self,
4166 max: usize,
4167 window: Duration,
4168 ) -> Result<Self, ConfigError> {
4169 if max == 0 || max > MAX_WRITE_BATCH_MEMBERS {
4170 return Err(ConfigError::InvalidWriterBatchMax(max));
4171 }
4172 if window.is_zero() || window >= CLIENT_WRITE_WAIT_TIMEOUT {
4173 return Err(ConfigError::InvalidWriterBatchWindow);
4174 }
4175 self.writer_batch_max = max;
4176 self.writer_batch_window = window;
4177 Ok(self)
4178 }
4179
4180 pub fn with_log_initial_configuration(mut self, configuration: ConfigurationState) -> Self {
4181 self.log_initial_configuration = configuration;
4182 self
4183 }
4184
4185 pub fn with_predecessor_stop_entry(mut self, entry: LogEntry) -> Self {
4186 self.predecessor_stop_entry = Some(entry);
4187 self
4188 }
4189
4190 pub fn with_recovery_generation(
4191 mut self,
4192 recovery_generation: u64,
4193 ) -> Result<Self, ConfigError> {
4194 validate_recovery_generation(recovery_generation)?;
4195 self.recovery_generation = recovery_generation;
4196 Ok(self)
4197 }
4198
4199 pub fn cluster_id(&self) -> &str {
4200 &self.cluster_id
4201 }
4202
4203 pub fn logical_cluster_id(&self) -> &str {
4204 &self.logical_cluster_id
4205 }
4206
4207 pub fn node_id(&self) -> &str {
4208 &self.node_id
4209 }
4210
4211 pub fn data_dir(&self) -> &PathBuf {
4212 &self.data_dir
4213 }
4214
4215 pub const fn epoch(&self) -> u64 {
4216 self.epoch
4217 }
4218
4219 pub const fn config_id(&self) -> u64 {
4220 self.configuration_state.config_id()
4221 }
4222
4223 pub const fn recovery_generation(&self) -> u64 {
4224 self.recovery_generation
4225 }
4226
4227 pub fn peers(&self) -> &[PeerConfig] {
4228 &self.peers
4229 }
4230
4231 pub const fn membership(&self) -> &Membership {
4232 &self.membership
4233 }
4234
4235 pub const fn configuration_state(&self) -> &ConfigurationState {
4236 &self.configuration_state
4237 }
4238
4239 pub const fn log_initial_configuration(&self) -> &ConfigurationState {
4240 &self.log_initial_configuration
4241 }
4242
4243 pub fn client_token(&self) -> &str {
4244 &self.client_token
4245 }
4246
4247 pub const fn read_consistency(&self) -> ReadConsistency {
4248 self.read_consistency
4249 }
4250
4251 pub const fn ack_mode(&self) -> AckMode {
4252 self.ack_mode
4253 }
4254
4255 pub const fn writer_batch_max(&self) -> usize {
4256 self.writer_batch_max
4257 }
4258
4259 pub const fn writer_batch_window(&self) -> Duration {
4260 self.writer_batch_window
4261 }
4262
4263 pub const fn execution_profile(&self) -> ExecutionProfile {
4264 self.execution_profile
4265 }
4266
4267 #[cfg(feature = "sql")]
4268 pub const fn sql_write_profiler(&self) -> Option<&SqlWriteProfiler> {
4269 self.sql_write_profiler.as_ref()
4270 }
4271
4272 #[cfg(feature = "sql")]
4273 pub const fn sql_group_commit_queue_capacity(&self) -> usize {
4274 self.sql_group_commit_queue_capacity
4275 }
4276}
4277
4278fn validate_node_identity(
4279 cluster_id: &str,
4280 node_id: &str,
4281 data_dir: &Path,
4282 epoch: u64,
4283 config_id: u64,
4284) -> Result<(), ConfigError> {
4285 if cluster_id.trim().is_empty() {
4286 return Err(ConfigError::EmptyClusterId);
4287 }
4288 if node_id.trim().is_empty() {
4289 return Err(ConfigError::EmptyNodeId);
4290 }
4291 if data_dir.as_os_str().is_empty() {
4292 return Err(ConfigError::EmptyDataDir);
4293 }
4294 if epoch == 0 {
4295 return Err(ConfigError::InvalidEpoch);
4296 }
4297 if config_id == 0 {
4298 return Err(ConfigError::InvalidConfigId);
4299 }
4300 Ok(())
4301}
4302
4303fn membership_from_node_ids(members: Vec<String>) -> Result<Membership, ConfigError> {
4304 if !(3..=7).contains(&members.len()) {
4305 return Err(ConfigError::InvalidPeerCount(members.len()));
4306 }
4307 Membership::from_voters(members.clone()).map_err(|error| match error {
4308 rhiza_quepaxa::Error::DuplicateRecorderIdentity => {
4309 let duplicate = members
4310 .iter()
4311 .find(|candidate| {
4312 members
4313 .iter()
4314 .filter(|member| *member == *candidate)
4315 .count()
4316 > 1
4317 })
4318 .cloned()
4319 .unwrap_or_default();
4320 ConfigError::DuplicatePeerNodeId(duplicate)
4321 }
4322 rhiza_quepaxa::Error::EmptyRecorderIdentity => ConfigError::EmptyPeerNodeId,
4323 _ => ConfigError::InvalidPeerCount(members.len()),
4324 })
4325}
4326
4327fn membership_from_peers(peers: &[PeerConfig]) -> Result<Membership, ConfigError> {
4328 membership_from_node_ids(peers.iter().map(|peer| peer.node_id.clone()).collect())
4329}
4330
4331#[derive(Clone, Debug, Eq, PartialEq)]
4332pub enum NodeError {
4333 UnsupportedAckMode(AckMode),
4334 ExecutionProfileMismatch {
4335 expected: ExecutionProfile,
4336 actual: ExecutionProfile,
4337 },
4338 DataRootLocked(PathBuf),
4339 SnapshotRequired(Box<RecoveryAnchor>),
4340 Storage(String),
4341 Reconciliation(String),
4342 Invariant(String),
4343 Unavailable(String),
4344 ResourceExhausted(String),
4345 ConfigurationTransition {
4346 state: Box<ConfigurationState>,
4347 },
4348 Contention(String),
4349 WinnerLimitExceeded,
4350 #[cfg(feature = "sql")]
4351 RequestConflict(RequestConflict),
4352 InvalidRequest(String),
4353 #[cfg(feature = "sql")]
4354 InvalidSqlStatement {
4355 statement_index: usize,
4356 message: String,
4357 },
4358 PreconditionFailed(String),
4359 Fatal(String),
4360}
4361
4362impl fmt::Display for NodeError {
4363 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4364 match self {
4365 Self::UnsupportedAckMode(mode) => {
4366 write!(
4367 f,
4368 "ack mode {mode:?} is unsupported without synchronous archive"
4369 )
4370 }
4371 Self::ExecutionProfileMismatch { expected, actual } => write!(
4372 f,
4373 "execution profile mismatch: expected {expected}, got {actual}"
4374 ),
4375 Self::DataRootLocked(path) => {
4376 write!(f, "node data root is already owned: {}", path.display())
4377 }
4378 Self::SnapshotRequired(anchor) => write!(
4379 f,
4380 "snapshot restore required at qlog anchor {}",
4381 anchor.compacted().index()
4382 ),
4383 Self::Storage(message) => write!(f, "node storage failed: {message}"),
4384 Self::Reconciliation(message) => write!(f, "node reconciliation failed: {message}"),
4385 Self::Invariant(message) => write!(f, "node invariant failed: {message}"),
4386 Self::Unavailable(message) => write!(f, "node unavailable: {message}"),
4387 Self::ResourceExhausted(message) => {
4388 write!(f, "node query resources exhausted: {message}")
4389 }
4390 Self::ConfigurationTransition { state } => write!(
4391 f,
4392 "node unavailable during configuration transition: {state:?}"
4393 ),
4394 Self::Contention(message) => write!(f, "node contention: {message}"),
4395 Self::WinnerLimitExceeded => write!(f, "foreign winner retry limit exceeded"),
4396 #[cfg(feature = "sql")]
4397 Self::RequestConflict(conflict) => conflict.fmt(f),
4398 Self::InvalidRequest(message) => write!(f, "invalid request: {message}"),
4399 #[cfg(feature = "sql")]
4400 Self::InvalidSqlStatement {
4401 statement_index,
4402 message,
4403 } => write!(
4404 f,
4405 "invalid SQL statement at index {statement_index}: {message}"
4406 ),
4407 Self::PreconditionFailed(message) => write!(f, "precondition failed: {message}"),
4408 Self::Fatal(message) => write!(f, "node is fatally unavailable: {message}"),
4409 }
4410 }
4411}
4412
4413impl std::error::Error for NodeError {}
4414
4415impl NodeError {
4416 pub fn classification(&self) -> ErrorClassification {
4417 let (code, retryable) = match self {
4418 Self::InvalidRequest(_) => ("invalid_request", false),
4419 #[cfg(feature = "sql")]
4420 Self::InvalidSqlStatement { .. } => ("invalid_request", false),
4421 #[cfg(feature = "sql")]
4422 Self::RequestConflict(_) => ("request_conflict", false),
4423 Self::PreconditionFailed(_) => ("precondition_failed", false),
4424 Self::SnapshotRequired(_) => ("snapshot_required", false),
4425 Self::Unavailable(_) => ("unavailable", true),
4426 Self::ResourceExhausted(_) => ("resource_exhausted", true),
4427 Self::ConfigurationTransition { .. } => ("configuration_transition", true),
4428 Self::Contention(_) => ("contention", true),
4429 Self::WinnerLimitExceeded => ("winner_limit_exceeded", true),
4430 Self::DataRootLocked(_) => ("data_root_locked", false),
4431 Self::UnsupportedAckMode(_) => ("unsupported_ack_mode", false),
4432 Self::ExecutionProfileMismatch { .. } => ("execution_profile_mismatch", false),
4433 Self::Storage(_) => ("storage_error", false),
4434 Self::Reconciliation(_) => ("reconciliation_error", false),
4435 Self::Invariant(_) => ("invariant_violation", false),
4436 Self::Fatal(_) => ("fatal", false),
4437 };
4438 ErrorClassification::from_server_code(code, retryable)
4439 }
4440}
4441
4442pub type RuntimeError = NodeError;
4443
4444#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4445#[serde(rename_all = "snake_case")]
4446pub enum RuntimeConfigurationStatus {
4447 Active,
4448 Stopped,
4449 AwaitingActivation,
4450}
4451
4452#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4453pub struct NodeStatus {
4454 pub ready: bool,
4455 pub configuration_status: RuntimeConfigurationStatus,
4456 pub configuration_state: ConfigurationState,
4457 pub stop_anchor: Option<rhiza_core::LogAnchor>,
4458 pub active_config_id: u64,
4459 pub active_membership_digest: LogHash,
4460}
4461
4462#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4463pub struct StopInformation {
4464 pub version: u16,
4465 pub entry: LogEntry,
4466 pub proof: DecisionProof,
4467}
4468
4469#[cfg(feature = "sql")]
4470#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4471#[serde(deny_unknown_fields)]
4472pub struct WriteRequest {
4473 pub request_id: String,
4474 pub key: String,
4475 pub value: String,
4476}
4477
4478#[cfg(feature = "sql")]
4479#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4480pub struct WriteResponse {
4481 pub applied_index: LogIndex,
4482 pub hash: LogHash,
4483}
4484
4485#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4486pub struct ClientErrorResponse {
4487 pub code: String,
4488 pub retryable: bool,
4489 pub message: String,
4490 #[serde(default, skip_serializing_if = "Option::is_none")]
4491 pub statement_index: Option<usize>,
4492}
4493
4494#[cfg(feature = "sql")]
4495#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4496#[serde(deny_unknown_fields)]
4497pub struct ReadRequest {
4498 pub key: String,
4499 pub consistency: Option<ReadConsistency>,
4500}
4501
4502#[cfg(feature = "sql")]
4503#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4504pub struct ReadResponse {
4505 pub value: Option<String>,
4506 pub applied_index: LogIndex,
4507 pub hash: LogHash,
4508}
4509
4510#[cfg(feature = "sql")]
4511#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4512#[serde(deny_unknown_fields)]
4513pub struct SqlExecuteRequest {
4514 pub request_id: String,
4515 pub statements: Vec<SqlStatement>,
4516}
4517
4518#[cfg(feature = "sql")]
4519#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4520pub struct SqlExecuteResponse {
4521 pub version: u16,
4522 pub applied_index: LogIndex,
4523 pub hash: LogHash,
4524 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4525 pub results: Vec<SqlStatementResult>,
4526}
4527
4528#[cfg(feature = "sql")]
4529impl From<WriteResponse> for SqlExecuteResponse {
4530 fn from(response: WriteResponse) -> Self {
4531 sql_execute_response(response, None)
4532 }
4533}
4534
4535#[cfg(feature = "sql")]
4536fn sql_execute_response(
4537 response: WriteResponse,
4538 result: Option<SqlCommandResult>,
4539) -> SqlExecuteResponse {
4540 let results = result
4541 .map(|result| {
4542 result
4543 .statement_results
4544 .into_iter()
4545 .enumerate()
4546 .map(|(statement_index, result)| SqlStatementResult {
4547 statement_index,
4548 rows_affected: result.rows_affected,
4549 returning: result.returning,
4550 })
4551 .collect()
4552 })
4553 .unwrap_or_default();
4554 SqlExecuteResponse {
4555 version: SQL_EXECUTE_RESPONSE_VERSION,
4556 applied_index: response.applied_index,
4557 hash: response.hash,
4558 results,
4559 }
4560}
4561
4562#[cfg(feature = "sql")]
4563#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4564pub struct SqlStatementResult {
4565 pub statement_index: usize,
4566 pub rows_affected: u64,
4567 #[serde(default, skip_serializing_if = "Option::is_none")]
4568 pub returning: Option<SqlQueryResult>,
4569}
4570
4571#[cfg(feature = "sql")]
4572#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4573#[serde(deny_unknown_fields)]
4574pub struct SqlQueryRequest {
4575 pub statement: SqlStatement,
4576 pub consistency: Option<ReadConsistency>,
4577 pub max_rows: Option<u32>,
4578}
4579
4580#[cfg(feature = "sql")]
4581#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4582pub struct SqlQueryResponse {
4583 pub columns: Vec<String>,
4584 pub rows: Vec<Vec<SqlValue>>,
4585 pub applied_index: LogIndex,
4586 pub hash: LogHash,
4587}
4588
4589#[cfg(feature = "graph")]
4590#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4591#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4592pub enum GraphValueDto {
4593 Null,
4594 Bool(bool),
4595 I64(i64),
4596 U64(u64),
4597 F64(f64),
4598 String(String),
4599 Bytes(String),
4600}
4601
4602#[cfg(feature = "graph")]
4603#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4604#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4605pub enum GraphQueryParameterDto {
4606 Null,
4607 Bool(bool),
4608 I64(i64),
4609 U64(u64),
4610 F64(f64),
4611 String(String),
4612 Bytes(String),
4613 List(Vec<Self>),
4614 Struct(BTreeMap<String, Self>),
4615}
4616
4617#[cfg(feature = "graph")]
4618impl TryFrom<GraphQueryParameterDto> for GraphParameterValue {
4619 type Error = NodeError;
4620
4621 fn try_from(value: GraphQueryParameterDto) -> Result<Self, Self::Error> {
4622 Ok(match value {
4623 GraphQueryParameterDto::Null => Self::Null,
4624 GraphQueryParameterDto::Bool(value) => Self::Bool(value),
4625 GraphQueryParameterDto::I64(value) => Self::I64(value),
4626 GraphQueryParameterDto::U64(value) => Self::U64(value),
4627 GraphQueryParameterDto::F64(value) => Self::F64(
4628 CanonicalF64::new(value)
4629 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?,
4630 ),
4631 GraphQueryParameterDto::String(value) => Self::String(value),
4632 GraphQueryParameterDto::Bytes(value) => {
4633 Self::Bytes(decode_base64("graph parameter bytes", &value)?)
4634 }
4635 GraphQueryParameterDto::List(values) => Self::List(
4636 values
4637 .into_iter()
4638 .map(Self::try_from)
4639 .collect::<Result<_, _>>()?,
4640 ),
4641 GraphQueryParameterDto::Struct(values) => Self::Struct(
4642 values
4643 .into_iter()
4644 .map(|(name, value)| Self::try_from(value).map(|value| (name, value)))
4645 .collect::<Result<_, _>>()?,
4646 ),
4647 })
4648 }
4649}
4650
4651#[cfg(feature = "graph")]
4652#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4653#[serde(deny_unknown_fields)]
4654pub struct GraphQueryStatementDto {
4655 pub cypher: String,
4656 #[serde(default)]
4657 pub parameters: BTreeMap<String, GraphQueryParameterDto>,
4658}
4659
4660#[cfg(feature = "graph")]
4661#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4662#[serde(deny_unknown_fields)]
4663pub struct GraphQueryRequest {
4664 pub statement: GraphQueryStatementDto,
4665 pub consistency: Option<ReadConsistency>,
4666 pub max_rows: Option<u32>,
4667}
4668
4669#[cfg(feature = "graph")]
4670#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4671pub struct GraphInternalIdDto {
4672 pub offset: u64,
4673 pub table_id: u64,
4674}
4675
4676#[cfg(feature = "graph")]
4677impl From<GraphInternalId> for GraphInternalIdDto {
4678 fn from(value: GraphInternalId) -> Self {
4679 Self {
4680 offset: value.offset,
4681 table_id: value.table_id,
4682 }
4683 }
4684}
4685
4686#[cfg(feature = "graph")]
4687#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4688pub struct GraphNamedValueDto {
4689 pub name: String,
4690 pub value: GraphResultValueDto,
4691}
4692
4693#[cfg(feature = "graph")]
4694#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4695pub struct GraphNodeDto {
4696 pub id: GraphInternalIdDto,
4697 pub label: String,
4698 pub properties: Vec<GraphNamedValueDto>,
4699}
4700
4701#[cfg(feature = "graph")]
4702impl From<GraphNode> for GraphNodeDto {
4703 fn from(value: GraphNode) -> Self {
4704 Self {
4705 id: value.id.into(),
4706 label: value.label,
4707 properties: named_graph_values(value.properties),
4708 }
4709 }
4710}
4711
4712#[cfg(feature = "graph")]
4713#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4714pub struct GraphRelDto {
4715 pub src: GraphInternalIdDto,
4716 pub dst: GraphInternalIdDto,
4717 pub label: String,
4718 pub properties: Vec<GraphNamedValueDto>,
4719}
4720
4721#[cfg(feature = "graph")]
4722impl From<GraphRel> for GraphRelDto {
4723 fn from(value: GraphRel) -> Self {
4724 Self {
4725 src: value.src.into(),
4726 dst: value.dst.into(),
4727 label: value.label,
4728 properties: named_graph_values(value.properties),
4729 }
4730 }
4731}
4732
4733#[cfg(feature = "graph")]
4734#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4735pub struct GraphRecursiveRelDto {
4736 pub nodes: Vec<GraphNodeDto>,
4737 pub rels: Vec<GraphRelDto>,
4738}
4739
4740#[cfg(feature = "graph")]
4741#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4742pub struct GraphMapEntryDto {
4743 pub key: GraphResultValueDto,
4744 pub value: GraphResultValueDto,
4745}
4746
4747#[cfg(feature = "graph")]
4748#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4749pub struct GraphNamedLogicalTypeDto {
4750 pub name: String,
4751 pub logical_type: GraphLogicalTypeDto,
4752}
4753
4754#[cfg(feature = "graph")]
4755#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4756pub struct GraphArrayTypeDto {
4757 pub element_type: Box<GraphLogicalTypeDto>,
4758 pub length: u64,
4759}
4760
4761#[cfg(feature = "graph")]
4762#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4763pub struct GraphMapTypeDto {
4764 pub key_type: Box<GraphLogicalTypeDto>,
4765 pub value_type: Box<GraphLogicalTypeDto>,
4766}
4767
4768#[cfg(feature = "graph")]
4769#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4770pub struct GraphDecimalTypeDto {
4771 pub precision: u32,
4772 pub scale: u32,
4773}
4774
4775#[cfg(feature = "graph")]
4776#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
4777#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4778pub enum GraphLogicalTypeDto {
4779 Any,
4780 Bool,
4781 Serial,
4782 I64,
4783 I32,
4784 I16,
4785 I8,
4786 U64,
4787 U32,
4788 U16,
4789 U8,
4790 I128,
4791 F64,
4792 F32,
4793 Date,
4794 Interval,
4795 Timestamp,
4796 TimestampTz,
4797 TimestampNs,
4798 TimestampMs,
4799 TimestampSec,
4800 InternalId,
4801 String,
4802 Json,
4803 Bytes,
4804 List(Box<Self>),
4805 Array(GraphArrayTypeDto),
4806 Struct(Vec<GraphNamedLogicalTypeDto>),
4807 Node,
4808 Rel,
4809 RecursiveRel,
4810 Map(GraphMapTypeDto),
4811 Union(Vec<GraphNamedLogicalTypeDto>),
4812 Uuid,
4813 Decimal(GraphDecimalTypeDto),
4814}
4815
4816#[cfg(feature = "graph")]
4817impl From<GraphLogicalType> for GraphLogicalTypeDto {
4818 fn from(value: GraphLogicalType) -> Self {
4819 match value {
4820 GraphLogicalType::Any => Self::Any,
4821 GraphLogicalType::Bool => Self::Bool,
4822 GraphLogicalType::Serial => Self::Serial,
4823 GraphLogicalType::I64 => Self::I64,
4824 GraphLogicalType::I32 => Self::I32,
4825 GraphLogicalType::I16 => Self::I16,
4826 GraphLogicalType::I8 => Self::I8,
4827 GraphLogicalType::U64 => Self::U64,
4828 GraphLogicalType::U32 => Self::U32,
4829 GraphLogicalType::U16 => Self::U16,
4830 GraphLogicalType::U8 => Self::U8,
4831 GraphLogicalType::I128 => Self::I128,
4832 GraphLogicalType::F64 => Self::F64,
4833 GraphLogicalType::F32 => Self::F32,
4834 GraphLogicalType::Date => Self::Date,
4835 GraphLogicalType::Interval => Self::Interval,
4836 GraphLogicalType::Timestamp => Self::Timestamp,
4837 GraphLogicalType::TimestampTz => Self::TimestampTz,
4838 GraphLogicalType::TimestampNs => Self::TimestampNs,
4839 GraphLogicalType::TimestampMs => Self::TimestampMs,
4840 GraphLogicalType::TimestampSec => Self::TimestampSec,
4841 GraphLogicalType::InternalId => Self::InternalId,
4842 GraphLogicalType::String => Self::String,
4843 GraphLogicalType::Json => Self::Json,
4844 GraphLogicalType::Bytes => Self::Bytes,
4845 GraphLogicalType::List(element_type) => Self::List(Box::new((*element_type).into())),
4846 GraphLogicalType::Array {
4847 element_type,
4848 length,
4849 } => Self::Array(GraphArrayTypeDto {
4850 element_type: Box::new((*element_type).into()),
4851 length,
4852 }),
4853 GraphLogicalType::Struct(fields) => Self::Struct(named_graph_logical_types(fields)),
4854 GraphLogicalType::Node => Self::Node,
4855 GraphLogicalType::Rel => Self::Rel,
4856 GraphLogicalType::RecursiveRel => Self::RecursiveRel,
4857 GraphLogicalType::Map {
4858 key_type,
4859 value_type,
4860 } => Self::Map(GraphMapTypeDto {
4861 key_type: Box::new((*key_type).into()),
4862 value_type: Box::new((*value_type).into()),
4863 }),
4864 GraphLogicalType::Union(types) => Self::Union(named_graph_logical_types(types)),
4865 GraphLogicalType::Uuid => Self::Uuid,
4866 GraphLogicalType::Decimal { precision, scale } => {
4867 Self::Decimal(GraphDecimalTypeDto { precision, scale })
4868 }
4869 }
4870 }
4871}
4872
4873#[cfg(feature = "graph")]
4874#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4875pub struct GraphCollectionValueDto {
4876 pub element_type: GraphLogicalTypeDto,
4877 pub values: Vec<GraphResultValueDto>,
4878}
4879
4880#[cfg(feature = "graph")]
4881#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4882pub struct GraphMapValueDto {
4883 pub key_type: GraphLogicalTypeDto,
4884 pub value_type: GraphLogicalTypeDto,
4885 pub entries: Vec<GraphMapEntryDto>,
4886}
4887
4888#[cfg(feature = "graph")]
4889#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4890pub struct GraphUnionValueDto {
4891 pub variants: Vec<GraphNamedLogicalTypeDto>,
4892 pub value: Box<GraphResultValueDto>,
4893}
4894
4895#[cfg(feature = "graph")]
4896#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
4897#[serde(tag = "type", content = "value", rename_all = "snake_case")]
4898pub enum GraphResultValueDto {
4899 Null(GraphLogicalTypeDto),
4900 Bool(bool),
4901 I64(i64),
4902 I32(i32),
4903 I16(i16),
4904 I8(i8),
4905 U64(u64),
4906 U32(u32),
4907 U16(u16),
4908 U8(u8),
4909 I128(String),
4910 F64(f64),
4911 F32(String),
4912 Date(String),
4913 Interval(String),
4914 Timestamp(String),
4915 TimestampTz(String),
4916 TimestampNs(String),
4917 TimestampMs(String),
4918 TimestampSec(String),
4919 InternalId(GraphInternalIdDto),
4920 String(String),
4921 Json(String),
4922 Bytes(String),
4923 List(GraphCollectionValueDto),
4924 Array(GraphCollectionValueDto),
4925 Struct(Vec<GraphNamedValueDto>),
4926 Node(GraphNodeDto),
4927 Rel(GraphRelDto),
4928 RecursiveRel(GraphRecursiveRelDto),
4929 Map(GraphMapValueDto),
4930 Union(GraphUnionValueDto),
4931 Uuid(String),
4932 Decimal(String),
4933}
4934
4935#[cfg(feature = "graph")]
4936impl From<GraphResultValue> for GraphResultValueDto {
4937 fn from(value: GraphResultValue) -> Self {
4938 match value {
4939 GraphResultValue::Null(value) => Self::Null(value.into()),
4940 GraphResultValue::Bool(value) => Self::Bool(value),
4941 GraphResultValue::I64(value) => Self::I64(value),
4942 GraphResultValue::I32(value) => Self::I32(value),
4943 GraphResultValue::I16(value) => Self::I16(value),
4944 GraphResultValue::I8(value) => Self::I8(value),
4945 GraphResultValue::U64(value) => Self::U64(value),
4946 GraphResultValue::U32(value) => Self::U32(value),
4947 GraphResultValue::U16(value) => Self::U16(value),
4948 GraphResultValue::U8(value) => Self::U8(value),
4949 GraphResultValue::I128(value) => Self::I128(value),
4950 GraphResultValue::F64(value) => Self::F64(value.get()),
4951 GraphResultValue::F32(value) => Self::F32(value),
4952 GraphResultValue::Date(value) => Self::Date(value),
4953 GraphResultValue::Interval(value) => Self::Interval(value),
4954 GraphResultValue::Timestamp(value) => Self::Timestamp(value),
4955 GraphResultValue::TimestampTz(value) => Self::TimestampTz(value),
4956 GraphResultValue::TimestampNs(value) => Self::TimestampNs(value),
4957 GraphResultValue::TimestampMs(value) => Self::TimestampMs(value),
4958 GraphResultValue::TimestampSec(value) => Self::TimestampSec(value),
4959 GraphResultValue::InternalId(value) => Self::InternalId(value.into()),
4960 GraphResultValue::String(value) => Self::String(value),
4961 GraphResultValue::Json(value) => Self::Json(value),
4962 GraphResultValue::Bytes(value) => Self::Bytes(encode_base64(&value)),
4963 GraphResultValue::List {
4964 element_type,
4965 values,
4966 } => Self::List(GraphCollectionValueDto {
4967 element_type: element_type.into(),
4968 values: values.into_iter().map(Self::from).collect(),
4969 }),
4970 GraphResultValue::Array {
4971 element_type,
4972 values,
4973 } => Self::Array(GraphCollectionValueDto {
4974 element_type: element_type.into(),
4975 values: values.into_iter().map(Self::from).collect(),
4976 }),
4977 GraphResultValue::Struct(values) => Self::Struct(named_graph_values(values)),
4978 GraphResultValue::Node(value) => Self::Node(value.into()),
4979 GraphResultValue::Rel(value) => Self::Rel(value.into()),
4980 GraphResultValue::RecursiveRel { nodes, rels } => {
4981 Self::RecursiveRel(GraphRecursiveRelDto {
4982 nodes: nodes.into_iter().map(Into::into).collect(),
4983 rels: rels.into_iter().map(Into::into).collect(),
4984 })
4985 }
4986 GraphResultValue::Map {
4987 key_type,
4988 value_type,
4989 entries,
4990 } => Self::Map(GraphMapValueDto {
4991 key_type: key_type.into(),
4992 value_type: value_type.into(),
4993 entries: entries
4994 .into_iter()
4995 .map(|(key, value)| GraphMapEntryDto {
4996 key: key.into(),
4997 value: value.into(),
4998 })
4999 .collect(),
5000 }),
5001 GraphResultValue::Union { variants, value } => Self::Union(GraphUnionValueDto {
5002 variants: named_graph_logical_types(variants),
5003 value: Box::new(Self::from(*value)),
5004 }),
5005 GraphResultValue::Uuid(value) => Self::Uuid(value),
5006 GraphResultValue::Decimal(value) => Self::Decimal(value),
5007 }
5008 }
5009}
5010
5011#[cfg(feature = "graph")]
5012fn named_graph_values(values: Vec<(String, GraphResultValue)>) -> Vec<GraphNamedValueDto> {
5013 values
5014 .into_iter()
5015 .map(|(name, value)| GraphNamedValueDto {
5016 name,
5017 value: value.into(),
5018 })
5019 .collect()
5020}
5021
5022#[cfg(feature = "graph")]
5023fn named_graph_logical_types(
5024 values: Vec<(String, GraphLogicalType)>,
5025) -> Vec<GraphNamedLogicalTypeDto> {
5026 values
5027 .into_iter()
5028 .map(|(name, logical_type)| GraphNamedLogicalTypeDto {
5029 name,
5030 logical_type: logical_type.into(),
5031 })
5032 .collect()
5033}
5034
5035#[cfg(feature = "graph")]
5036#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5037pub struct GraphColumnDto {
5038 pub name: String,
5039 pub logical_type: GraphLogicalTypeDto,
5040}
5041
5042#[cfg(feature = "graph")]
5043impl From<GraphColumn> for GraphColumnDto {
5044 fn from(value: GraphColumn) -> Self {
5045 Self {
5046 name: value.name,
5047 logical_type: value.logical_type.into(),
5048 }
5049 }
5050}
5051
5052#[cfg(feature = "graph")]
5053#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5054pub struct GraphQueryResponse {
5055 pub columns: Vec<GraphColumnDto>,
5056 pub rows: Vec<Vec<GraphResultValueDto>>,
5057 pub applied_index: LogIndex,
5058 pub hash: LogHash,
5059}
5060
5061#[cfg(feature = "graph")]
5062impl From<GraphQueryResult> for GraphQueryResponse {
5063 fn from(value: GraphQueryResult) -> Self {
5064 Self {
5065 columns: value.columns.into_iter().map(Into::into).collect(),
5066 rows: value
5067 .rows
5068 .into_iter()
5069 .map(|row| row.into_iter().map(Into::into).collect())
5070 .collect(),
5071 applied_index: value.applied_index,
5072 hash: value.hash,
5073 }
5074 }
5075}
5076
5077#[cfg(feature = "graph")]
5078#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5079#[serde(deny_unknown_fields)]
5080pub struct GraphPutDocumentRequest {
5081 pub request_id: String,
5082 pub id: String,
5083 pub value: GraphValueDto,
5084}
5085
5086#[cfg(feature = "graph")]
5087#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5088#[serde(deny_unknown_fields)]
5089pub struct GraphDeleteDocumentRequest {
5090 pub request_id: String,
5091 pub id: String,
5092}
5093
5094#[cfg(feature = "graph")]
5095#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5096#[serde(deny_unknown_fields)]
5097pub struct GraphGetDocumentRequest {
5098 pub id: String,
5099 pub consistency: Option<ReadConsistency>,
5100}
5101
5102#[cfg(feature = "graph")]
5103#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5104#[serde(tag = "operation", rename_all = "snake_case")]
5105pub enum GraphMutationResultDto {
5106 PutDocument { created: bool },
5107 DeleteDocument { existed: bool },
5108}
5109
5110#[cfg(feature = "graph")]
5111#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5112pub struct GraphMutationResponse {
5113 pub applied_index: LogIndex,
5114 pub hash: LogHash,
5115 pub result: GraphMutationResultDto,
5116}
5117
5118#[cfg(feature = "graph")]
5119#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
5120pub struct GraphGetDocumentResponse {
5121 pub value: Option<GraphValueDto>,
5122 pub applied_index: LogIndex,
5123 pub hash: LogHash,
5124}
5125
5126#[cfg(feature = "kv")]
5127#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5128#[serde(deny_unknown_fields)]
5129pub struct KvPutRequest {
5130 pub request_id: String,
5131 pub key: String,
5132 pub value: String,
5133}
5134
5135#[cfg(feature = "kv")]
5136#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5137#[serde(deny_unknown_fields)]
5138pub struct KvDeleteRequest {
5139 pub request_id: String,
5140 pub key: String,
5141}
5142
5143#[cfg(feature = "kv")]
5144#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5145#[serde(deny_unknown_fields)]
5146pub struct KvGetRequest {
5147 pub key: String,
5148 pub consistency: Option<ReadConsistency>,
5149}
5150
5151#[cfg(feature = "kv")]
5152#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5153#[serde(deny_unknown_fields)]
5154pub struct KvScanRequest {
5155 pub start: Option<String>,
5156 pub end: Option<String>,
5157 pub prefix: Option<String>,
5158 pub cursor: Option<String>,
5159 pub limit: Option<usize>,
5160 pub consistency: Option<ReadConsistency>,
5161}
5162
5163#[cfg(feature = "kv")]
5164#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5165#[serde(tag = "operation", rename_all = "snake_case")]
5166pub enum KvMutationResultDto {
5167 Put { replaced: bool },
5168 Delete { existed: bool },
5169}
5170
5171#[cfg(feature = "kv")]
5172#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5173pub struct KvMutationResponse {
5174 pub applied_index: LogIndex,
5175 pub hash: LogHash,
5176 pub result: KvMutationResultDto,
5177}
5178
5179#[cfg(feature = "kv")]
5180#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5181pub struct KvGetResponse {
5182 pub value: Option<String>,
5183 pub applied_index: LogIndex,
5184 pub hash: LogHash,
5185}
5186
5187#[cfg(feature = "kv")]
5188#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5189pub struct KvScanEntryDto {
5190 pub key: String,
5191 pub value: String,
5192}
5193
5194#[cfg(feature = "kv")]
5195#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5196pub struct KvScanResponse {
5197 pub entries: Vec<KvScanEntryDto>,
5198 pub next_cursor: Option<String>,
5199 pub applied_index: LogIndex,
5200 pub hash: LogHash,
5201}
5202
5203#[cfg(feature = "graph")]
5204fn graph_mutation_response(outcome: GraphMutationOutcome) -> GraphMutationResponse {
5205 GraphMutationResponse {
5206 applied_index: outcome.applied_index(),
5207 hash: outcome.hash(),
5208 result: match outcome.result() {
5209 GraphCommandResultV1::PutDocument { created } => {
5210 GraphMutationResultDto::PutDocument { created: *created }
5211 }
5212 GraphCommandResultV1::DeleteDocument { existed } => {
5213 GraphMutationResultDto::DeleteDocument { existed: *existed }
5214 }
5215 },
5216 }
5217}
5218
5219#[cfg(feature = "kv")]
5220fn kv_mutation_response(outcome: KvMutationOutcome) -> KvMutationResponse {
5221 KvMutationResponse {
5222 applied_index: outcome.applied_index(),
5223 hash: outcome.hash(),
5224 result: match outcome.result() {
5225 KvCommandResultV1::Put { replaced } => KvMutationResultDto::Put {
5226 replaced: *replaced,
5227 },
5228 KvCommandResultV1::Delete { existed } => {
5229 KvMutationResultDto::Delete { existed: *existed }
5230 }
5231 },
5232 }
5233}
5234
5235#[cfg(feature = "kv")]
5236fn validate_kv_scan_required_index(
5237 result: &KvScanResult,
5238 required_index: Option<LogIndex>,
5239) -> Result<(), NodeError> {
5240 let applied_index = result.tip().applied_index();
5241 if required_index.is_some_and(|required| applied_index < required) {
5242 return Err(NodeError::Unavailable(format!(
5243 "local applied index {applied_index} has not reached {}",
5244 required_index.expect("checked above")
5245 )));
5246 }
5247 Ok(())
5248}
5249
5250#[cfg(feature = "graph")]
5251impl TryFrom<GraphValueDto> for GraphValueV1 {
5252 type Error = NodeError;
5253
5254 fn try_from(value: GraphValueDto) -> Result<Self, Self::Error> {
5255 match value {
5256 GraphValueDto::Null => Ok(Self::Null),
5257 GraphValueDto::Bool(value) => Ok(Self::Bool(value)),
5258 GraphValueDto::I64(value) => Ok(Self::I64(value)),
5259 GraphValueDto::U64(value) => Ok(Self::U64(value)),
5260 GraphValueDto::F64(value) => {
5261 Self::from_f64(value).map_err(|error| NodeError::InvalidRequest(error.to_string()))
5262 }
5263 GraphValueDto::String(value) => Ok(Self::String(value)),
5264 GraphValueDto::Bytes(value) => decode_base64("value", &value).map(Self::Bytes),
5265 }
5266 }
5267}
5268
5269#[cfg(feature = "graph")]
5270impl From<GraphValueV1> for GraphValueDto {
5271 fn from(value: GraphValueV1) -> Self {
5272 match value {
5273 GraphValueV1::Null => Self::Null,
5274 GraphValueV1::Bool(value) => Self::Bool(value),
5275 GraphValueV1::I64(value) => Self::I64(value),
5276 GraphValueV1::U64(value) => Self::U64(value),
5277 GraphValueV1::F64(value) => Self::F64(value.get()),
5278 GraphValueV1::String(value) => Self::String(value),
5279 GraphValueV1::Bytes(value) => Self::Bytes(encode_base64(&value)),
5280 }
5281 }
5282}
5283
5284#[cfg(any(feature = "graph", feature = "kv"))]
5285fn encode_base64(bytes: &[u8]) -> String {
5286 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
5287 let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4);
5288 for chunk in bytes.chunks(3) {
5289 let first = chunk[0];
5290 let second = chunk.get(1).copied().unwrap_or(0);
5291 let third = chunk.get(2).copied().unwrap_or(0);
5292 encoded.push(ALPHABET[usize::from(first >> 2)] as char);
5293 encoded.push(ALPHABET[usize::from(((first & 0x03) << 4) | (second >> 4))] as char);
5294 if chunk.len() > 1 {
5295 encoded.push(ALPHABET[usize::from(((second & 0x0f) << 2) | (third >> 6))] as char);
5296 } else {
5297 encoded.push('=');
5298 }
5299 if chunk.len() > 2 {
5300 encoded.push(ALPHABET[usize::from(third & 0x3f)] as char);
5301 } else {
5302 encoded.push('=');
5303 }
5304 }
5305 encoded
5306}
5307
5308#[cfg(any(feature = "graph", feature = "kv"))]
5309fn decode_base64(field: &str, encoded: &str) -> Result<Vec<u8>, NodeError> {
5310 fn sextet(byte: u8) -> Option<u8> {
5311 match byte {
5312 b'A'..=b'Z' => Some(byte - b'A'),
5313 b'a'..=b'z' => Some(byte - b'a' + 26),
5314 b'0'..=b'9' => Some(byte - b'0' + 52),
5315 b'+' => Some(62),
5316 b'/' => Some(63),
5317 _ => None,
5318 }
5319 }
5320
5321 let bytes = encoded.as_bytes();
5322 if !bytes.len().is_multiple_of(4) {
5323 return Err(NodeError::InvalidRequest(format!(
5324 "{field} must be canonical padded base64"
5325 )));
5326 }
5327 let mut decoded = Vec::with_capacity(bytes.len() / 4 * 3);
5328 for (chunk_index, chunk) in bytes.chunks_exact(4).enumerate() {
5329 let last = chunk_index + 1 == bytes.len() / 4;
5330 let first = sextet(chunk[0]);
5331 let second = sextet(chunk[1]);
5332 let third = (chunk[2] != b'=').then(|| sextet(chunk[2])).flatten();
5333 let fourth = (chunk[3] != b'=').then(|| sextet(chunk[3])).flatten();
5334 let has_padding = chunk[2] == b'=' || chunk[3] == b'=';
5335 if first.is_none()
5336 || second.is_none()
5337 || (chunk[2] != b'=' && third.is_none())
5338 || (chunk[3] != b'=' && fourth.is_none())
5339 || (!last && has_padding)
5340 || (chunk[2] == b'=' && chunk[3] != b'=')
5341 {
5342 return Err(NodeError::InvalidRequest(format!(
5343 "{field} must be canonical padded base64"
5344 )));
5345 }
5346 let first = first.unwrap();
5347 let second = second.unwrap();
5348 decoded.push((first << 2) | (second >> 4));
5349 if let Some(third) = third {
5350 decoded.push((second << 4) | (third >> 2));
5351 if let Some(fourth) = fourth {
5352 decoded.push((third << 6) | fourth);
5353 } else if third & 0x03 != 0 {
5354 return Err(NodeError::InvalidRequest(format!(
5355 "{field} must be canonical padded base64"
5356 )));
5357 }
5358 } else if second & 0x0f != 0 {
5359 return Err(NodeError::InvalidRequest(format!(
5360 "{field} must be canonical padded base64"
5361 )));
5362 }
5363 }
5364 Ok(decoded)
5365}
5366
5367enum Materializer {
5368 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5369 Unavailable,
5370 #[cfg(feature = "sql")]
5371 Sql(Box<SqliteStateMachine>),
5372 #[cfg(feature = "graph")]
5373 Graph(Arc<LadybugStateMachine>),
5374 #[cfg(feature = "kv")]
5375 Kv(Arc<RedbStateMachine>),
5376}
5377
5378#[cfg(any(feature = "sql", feature = "kv"))]
5379fn quarantine_materializer(data_dir: &Path, directory: &str) -> Result<(), NodeError> {
5380 static SEQUENCE: AtomicUsize = AtomicUsize::new(0);
5381 let source = data_dir.join(directory);
5382 if !source.exists() {
5383 return Ok(());
5384 }
5385 loop {
5386 let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
5387 let target = data_dir.join(format!(
5388 "{directory}.quarantine-{}-{sequence}",
5389 std::process::id()
5390 ));
5391 match fs::rename(&source, target) {
5392 Ok(()) => return Ok(()),
5393 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
5394 Err(error) => return Err(NodeError::Storage(error.to_string())),
5395 }
5396 }
5397}
5398
5399#[cfg(feature = "sql")]
5400struct SqlMaterializerGuard<'a>(MutexGuard<'a, Materializer>);
5401
5402#[cfg(feature = "sql")]
5403impl std::ops::Deref for SqlMaterializerGuard<'_> {
5404 type Target = SqliteStateMachine;
5405
5406 fn deref(&self) -> &Self::Target {
5407 match &*self.0 {
5408 Materializer::Sql(state) => state,
5409 #[cfg(feature = "graph")]
5410 Materializer::Graph(_) => unreachable!("SQL guard validated the materializer profile"),
5411 #[cfg(feature = "kv")]
5412 Materializer::Kv(_) => unreachable!("SQL guard validated the materializer profile"),
5413 }
5414 }
5415}
5416
5417impl Materializer {
5418 fn ensure_profile_available(profile: ExecutionProfile) -> Result<(), NodeError> {
5419 if execution_profile_compiled(profile) {
5420 Ok(())
5421 } else {
5422 Err(NodeError::Unavailable(format!(
5423 "{} execution profile is not compiled in",
5424 profile.as_str()
5425 )))
5426 }
5427 }
5428
5429 #[cfg_attr(not(feature = "sql"), allow(unused_variables))]
5430 fn open(
5431 config: &NodeConfig,
5432 configuration_state: &ConfigurationState,
5433 recovery_anchor: Option<&RecoveryAnchor>,
5434 ) -> Result<Self, NodeError> {
5435 match config.execution_profile() {
5436 ExecutionProfile::Sqlite => {
5437 #[cfg(feature = "sql")]
5438 {
5439 let path = config.data_dir().join("sqlite/db.sqlite");
5440 let open = || {
5441 SqliteStateMachine::open_with_configuration(
5442 &path,
5443 config.cluster_id(),
5444 config.node_id(),
5445 config.epoch(),
5446 configuration_state.clone(),
5447 )
5448 };
5449 let state = match open() {
5450 Ok(state) => state,
5451 Err(_) => match recovery_anchor {
5452 Some(anchor) => {
5453 return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())))
5454 }
5455 None => {
5456 quarantine_materializer(config.data_dir(), "sqlite")?;
5457 open().map_err(|error| NodeError::Storage(error.to_string()))?
5458 }
5459 },
5460 };
5461 Ok(Self::Sql(Box::new(state)))
5462 }
5463 #[cfg(not(feature = "sql"))]
5464 Err(NodeError::Unavailable(
5465 "sql execution profile is not compiled in".into(),
5466 ))
5467 }
5468 ExecutionProfile::Graph => {
5469 #[cfg(feature = "graph")]
5470 {
5471 LadybugStateMachine::open(
5472 config.data_dir().join("ladybug/graph.lbug"),
5473 config.cluster_id(),
5474 config.node_id(),
5475 config.epoch(),
5476 configuration_state.config_id(),
5477 )
5478 .map(Arc::new)
5479 .map(Self::Graph)
5480 .map_err(|error| NodeError::Storage(error.to_string()))
5481 }
5482 #[cfg(not(feature = "graph"))]
5483 Err(NodeError::Unavailable(
5484 "graph execution profile is not compiled in".into(),
5485 ))
5486 }
5487 ExecutionProfile::Kv => {
5488 #[cfg(feature = "kv")]
5489 {
5490 let open = || {
5491 RedbStateMachine::open(
5492 config.data_dir().join("kv/data.redb"),
5493 config.cluster_id(),
5494 config.node_id(),
5495 config.epoch(),
5496 configuration_state.config_id(),
5497 )
5498 };
5499 let state = match open() {
5500 Ok(state) => state,
5501 Err(_) => match recovery_anchor {
5502 Some(anchor) => {
5503 return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())))
5504 }
5505 _ => {
5506 quarantine_materializer(config.data_dir(), "kv")?;
5507 open().map_err(|error| NodeError::Storage(error.to_string()))?
5508 }
5509 },
5510 };
5511 Ok(Self::Kv(Arc::new(state)))
5512 }
5513 #[cfg(not(feature = "kv"))]
5514 Err(NodeError::Unavailable(
5515 "kv execution profile is not compiled in".into(),
5516 ))
5517 }
5518 }
5519 }
5520
5521 fn profile(&self) -> ExecutionProfile {
5522 match self {
5523 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5524 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5525 #[cfg(feature = "sql")]
5526 Self::Sql(_) => ExecutionProfile::Sqlite,
5527 #[cfg(feature = "graph")]
5528 Self::Graph(_) => ExecutionProfile::Graph,
5529 #[cfg(feature = "kv")]
5530 Self::Kv(_) => ExecutionProfile::Kv,
5531 }
5532 }
5533
5534 fn applied_index(&self) -> Result<LogIndex, String> {
5535 match self {
5536 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5537 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5538 #[cfg(feature = "sql")]
5539 Self::Sql(state) => state
5540 .applied_index_value()
5541 .map_err(|error| error.to_string()),
5542 #[cfg(feature = "graph")]
5543 Self::Graph(state) => state.applied_index().map_err(|error| error.to_string()),
5544 #[cfg(feature = "kv")]
5545 Self::Kv(state) => state.applied_index().map_err(|error| error.to_string()),
5546 }
5547 }
5548
5549 fn applied_hash(&self) -> Result<LogHash, String> {
5550 match self {
5551 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5552 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5553 #[cfg(feature = "sql")]
5554 Self::Sql(state) => state
5555 .applied_hash_value()
5556 .map_err(|error| error.to_string()),
5557 #[cfg(feature = "graph")]
5558 Self::Graph(state) => state.applied_hash().map_err(|error| error.to_string()),
5559 #[cfg(feature = "kv")]
5560 Self::Kv(state) => state.applied_hash().map_err(|error| error.to_string()),
5561 }
5562 }
5563
5564 fn applied_tip(&self) -> Result<LogAnchor, String> {
5565 match self {
5566 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5567 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5568 #[cfg(feature = "sql")]
5569 Self::Sql(state) => state
5570 .applied_tip()
5571 .map(|tip| LogAnchor::new(tip.applied_index(), tip.applied_hash()))
5572 .map_err(|error| error.to_string()),
5573 #[cfg(feature = "graph")]
5574 Self::Graph(state) => state.applied_tip().map_err(|error| error.to_string()),
5575 #[cfg(feature = "kv")]
5576 Self::Kv(state) => state.applied_tip().map_err(|error| error.to_string()),
5577 }
5578 }
5579
5580 fn configuration_state(&self) -> Result<Option<ConfigurationState>, String> {
5581 match self {
5582 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5583 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5584 #[cfg(feature = "sql")]
5585 Self::Sql(state) => state
5586 .configuration_state_value()
5587 .map(Some)
5588 .map_err(|error| error.to_string()),
5589 #[cfg(feature = "graph")]
5590 Self::Graph(_) => Ok(None),
5591 #[cfg(feature = "kv")]
5592 Self::Kv(_) => Ok(None),
5593 }
5594 }
5595
5596 fn apply_entry(&self, entry: &LogEntry) -> Result<Option<SqlCommandResult>, String> {
5597 match self {
5598 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
5599 Self::Unavailable => unreachable!("no execution profiles are compiled in"),
5600 #[cfg(feature = "sql")]
5601 Self::Sql(state) => state
5602 .apply_entry_with_result(entry)
5603 .map(|outcome| outcome.sql_result().cloned())
5604 .map_err(|error| error.to_string()),
5605 #[cfg(feature = "graph")]
5606 Self::Graph(state) => state
5607 .apply_entry(entry)
5608 .map(|_| None)
5609 .map_err(|error| error.to_string()),
5610 #[cfg(feature = "kv")]
5611 Self::Kv(state) => state
5612 .apply_entry(entry)
5613 .map(|_| None)
5614 .map_err(|error| error.to_string()),
5615 }
5616 }
5617}
5618
5619const READ_BARRIER_COALESCE_WINDOW: Duration = Duration::from_micros(50);
5620
5621struct ReadBarrierRounds {
5622 state: Mutex<ReadBarrierRoundsState>,
5623 collection_window: Duration,
5624}
5625
5626struct ReadBarrierRoundsState {
5627 tail: Option<Arc<ReadBarrierRound>>,
5628 next_generation: u64,
5629}
5630
5631struct ReadBarrierRound {
5632 #[cfg(test)]
5633 generation: u64,
5634 collection_deadline: Instant,
5635 predecessor: Mutex<Option<Arc<ReadBarrierRound>>>,
5636 phase: Mutex<ReadBarrierRoundPhase>,
5637 changed: Condvar,
5638}
5639
5640#[derive(Clone)]
5641enum ReadBarrierRoundPhase {
5642 Collecting,
5643 Running,
5644 Complete(Result<LogAnchor, NodeError>),
5645}
5646
5647struct ReadBarrierParticipant {
5648 round: Arc<ReadBarrierRound>,
5649 leader: bool,
5650}
5651
5652struct ReadBarrierPublication {
5653 round: Arc<ReadBarrierRound>,
5654 published: bool,
5655}
5656
5657impl ReadBarrierRounds {
5658 fn new(collection_window: Duration) -> Self {
5659 Self {
5660 state: Mutex::new(ReadBarrierRoundsState {
5661 tail: None,
5662 next_generation: 0,
5663 }),
5664 collection_window,
5665 }
5666 }
5667
5668 fn join(&self) -> Result<ReadBarrierParticipant, NodeError> {
5669 let mut state = self
5670 .state
5671 .lock()
5672 .map_err(|_| NodeError::Invariant("read barrier generation lock is poisoned".into()))?;
5673 if let Some(tail) = state.tail.as_ref() {
5674 let collecting = matches!(
5675 *tail.phase.lock().map_err(|_| {
5676 NodeError::Invariant("read barrier round lock is poisoned".into())
5677 })?,
5678 ReadBarrierRoundPhase::Collecting
5679 );
5680 if collecting {
5681 return Ok(ReadBarrierParticipant {
5682 round: Arc::clone(tail),
5683 leader: false,
5684 });
5685 }
5686 }
5687
5688 let generation = state
5689 .next_generation
5690 .checked_add(1)
5691 .ok_or_else(|| NodeError::Invariant("read barrier generation is exhausted".into()))?;
5692 state.next_generation = generation;
5693 let collection_deadline = Instant::now()
5694 .checked_add(self.collection_window)
5695 .unwrap_or_else(Instant::now);
5696 let round = Arc::new(ReadBarrierRound {
5697 #[cfg(test)]
5698 generation,
5699 collection_deadline,
5700 predecessor: Mutex::new(state.tail.clone()),
5701 phase: Mutex::new(ReadBarrierRoundPhase::Collecting),
5702 changed: Condvar::new(),
5703 });
5704 state.tail = Some(Arc::clone(&round));
5705 Ok(ReadBarrierParticipant {
5706 round,
5707 leader: true,
5708 })
5709 }
5710
5711 fn cancel_waiters(&self) {
5712 let mut round = self.state.lock().ok().and_then(|state| state.tail.clone());
5713 while let Some(current) = round {
5714 if let Ok(_phase) = current.phase.lock() {
5715 current.changed.notify_all();
5716 }
5717 round = current
5718 .predecessor
5719 .lock()
5720 .ok()
5721 .and_then(|predecessor| predecessor.clone());
5722 }
5723 }
5724}
5725
5726impl ReadBarrierRound {
5727 fn wait_complete(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
5728 let mut phase = self
5729 .phase
5730 .lock()
5731 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5732 loop {
5733 if matches!(*phase, ReadBarrierRoundPhase::Complete(_)) {
5734 return Ok(());
5735 }
5736 if cancelled.load(Ordering::Acquire) {
5737 return Err(NodeError::Unavailable(
5738 "read barrier cancelled during shutdown".into(),
5739 ));
5740 }
5741 phase = self
5742 .changed
5743 .wait(phase)
5744 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5745 }
5746 }
5747
5748 fn result(&self, cancelled: &AtomicBool) -> Result<LogAnchor, NodeError> {
5749 let mut phase = self
5750 .phase
5751 .lock()
5752 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5753 loop {
5754 if let ReadBarrierRoundPhase::Complete(result) = &*phase {
5755 return result.clone();
5756 }
5757 if cancelled.load(Ordering::Acquire) {
5758 return Err(NodeError::Unavailable(
5759 "read barrier cancelled during shutdown".into(),
5760 ));
5761 }
5762 phase = self
5763 .changed
5764 .wait(phase)
5765 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5766 }
5767 }
5768
5769 fn complete(&self, result: Result<LogAnchor, NodeError>) {
5770 if let Ok(mut phase) = self.phase.lock() {
5771 if !matches!(*phase, ReadBarrierRoundPhase::Complete(_)) {
5772 *phase = ReadBarrierRoundPhase::Complete(result);
5773 }
5774 self.changed.notify_all();
5775 } else {
5776 self.changed.notify_all();
5777 }
5778 }
5779}
5780
5781impl ReadBarrierParticipant {
5782 #[cfg(test)]
5783 fn generation(&self) -> u64 {
5784 self.round.generation
5785 }
5786
5787 #[cfg(test)]
5788 fn is_leader(&self) -> bool {
5789 self.leader
5790 }
5791
5792 fn publication(&self) -> Option<ReadBarrierPublication> {
5793 self.leader.then(|| ReadBarrierPublication {
5794 round: Arc::clone(&self.round),
5795 published: false,
5796 })
5797 }
5798
5799 fn wait(&self, cancelled: &AtomicBool) -> Result<LogAnchor, NodeError> {
5800 self.round.result(cancelled)
5801 }
5802}
5803
5804impl ReadBarrierPublication {
5805 fn wait_turn(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
5806 let predecessor = self
5807 .round
5808 .predecessor
5809 .lock()
5810 .map_err(|_| NodeError::Invariant("read barrier predecessor lock is poisoned".into()))?
5811 .clone();
5812 if let Some(predecessor) = predecessor {
5813 predecessor.wait_complete(cancelled)?;
5814 self.round
5815 .predecessor
5816 .lock()
5817 .map_err(|_| {
5818 NodeError::Invariant("read barrier predecessor lock is poisoned".into())
5819 })?
5820 .take();
5821 }
5822
5823 let mut phase = self
5824 .round
5825 .phase
5826 .lock()
5827 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5828 loop {
5829 if cancelled.load(Ordering::Acquire) {
5830 return Err(NodeError::Unavailable(
5831 "read barrier cancelled during shutdown".into(),
5832 ));
5833 }
5834 if !matches!(*phase, ReadBarrierRoundPhase::Collecting) {
5835 return Err(NodeError::Invariant(
5836 "read barrier leader left the collecting phase early".into(),
5837 ));
5838 }
5839 let now = Instant::now();
5840 if now >= self.round.collection_deadline {
5841 return Ok(());
5842 }
5843 let wait = self
5844 .round
5845 .collection_deadline
5846 .saturating_duration_since(now);
5847 let (next, _) =
5848 self.round.changed.wait_timeout(phase, wait).map_err(|_| {
5849 NodeError::Invariant("read barrier round lock is poisoned".into())
5850 })?;
5851 phase = next;
5852 }
5853 }
5854
5855 fn start(&self, cancelled: &AtomicBool) -> Result<(), NodeError> {
5856 let mut phase = self
5857 .round
5858 .phase
5859 .lock()
5860 .map_err(|_| NodeError::Invariant("read barrier round lock is poisoned".into()))?;
5861 if cancelled.load(Ordering::Acquire) {
5862 return Err(NodeError::Unavailable(
5863 "read barrier cancelled during shutdown".into(),
5864 ));
5865 }
5866 if !matches!(*phase, ReadBarrierRoundPhase::Collecting) {
5867 return Err(NodeError::Invariant(
5868 "read barrier generation cannot start twice".into(),
5869 ));
5870 }
5871 *phase = ReadBarrierRoundPhase::Running;
5872 Ok(())
5873 }
5874
5875 fn publish(&mut self, result: Result<LogAnchor, NodeError>) {
5876 self.round.complete(result);
5877 self.published = true;
5878 }
5879}
5880
5881impl Drop for ReadBarrierPublication {
5882 fn drop(&mut self) {
5883 if !self.published {
5884 self.round.complete(Err(NodeError::Unavailable(
5885 "read barrier generation leader terminated".into(),
5886 )));
5887 }
5888 }
5889}
5890
5891#[cfg(all(test, feature = "kv"))]
5892type KvGroupCommitAfterExecuteHook = Arc<dyn Fn(&NodeRuntime) + Send + Sync>;
5893
5894pub struct NodeRuntime {
5895 config: NodeConfig,
5896 consensus: Arc<ThreeNodeConsensus>,
5897 log_store: FileLogStore,
5898 materializer: Mutex<Materializer>,
5899 commit: Mutex<()>,
5900 #[cfg(feature = "sql")]
5901 sql_group_commit: SqlGroupCommitQueue,
5902 #[cfg(feature = "kv")]
5903 kv_group_commit: KvGroupCommitQueue,
5904 read_barriers: ReadBarrierRounds,
5905 checkpointing: AtomicBool,
5906 operation_cancelled: AtomicBool,
5907 operation_cancelled_notify: tokio::sync::Notify,
5908 ready: AtomicBool,
5909 fatal: AtomicBool,
5910 fatal_reason: Mutex<Option<String>>,
5911 #[cfg(test)]
5912 materialized_tip_checks: AtomicUsize,
5913 #[cfg(all(test, feature = "sql"))]
5914 sql_group_commit_before_execute_hook: Option<Arc<dyn Fn() + Send + Sync>>,
5915 #[cfg(all(test, feature = "kv"))]
5916 kv_group_commit_before_execute_hook: Option<Arc<dyn Fn() + Send + Sync>>,
5917 #[cfg(all(test, feature = "kv"))]
5918 kv_group_commit_after_execute_hook: Option<KvGroupCommitAfterExecuteHook>,
5919 _data_root_lock: fs::File,
5920}
5921
5922#[cfg(feature = "sql")]
5923struct ExecutedPayload {
5924 response: WriteResponse,
5925 sql_result: Option<SqlCommandResult>,
5926}
5927
5928#[cfg(feature = "sql")]
5929trait SqlWritePhaseProfile {
5930 type Mark;
5931
5932 fn mark(&self) -> Self::Mark;
5933 fn commit_lock_acquired(&mut self);
5934 fn add_precheck_classification(&mut self, mark: Self::Mark);
5935 fn add_qwal_prepare(&mut self, mark: Self::Mark);
5936 fn add_consensus_propose(&mut self, mark: Self::Mark);
5937 fn add_local_qlog_mirror_append(&mut self, mark: Self::Mark);
5938 fn add_sql_materializer_apply(&mut self, mark: Self::Mark);
5939 fn record_success(&mut self, batch_member_count: usize);
5940}
5941
5942#[cfg(feature = "sql")]
5943struct DisabledSqlWritePhaseProfile;
5944
5945#[cfg(feature = "sql")]
5946impl SqlWritePhaseProfile for DisabledSqlWritePhaseProfile {
5947 type Mark = ();
5948
5949 #[inline]
5950 fn mark(&self) {}
5951
5952 #[inline]
5953 fn commit_lock_acquired(&mut self) {}
5954
5955 #[inline]
5956 fn add_precheck_classification(&mut self, (): ()) {}
5957
5958 #[inline]
5959 fn add_qwal_prepare(&mut self, (): ()) {}
5960
5961 #[inline]
5962 fn add_consensus_propose(&mut self, (): ()) {}
5963
5964 #[inline]
5965 fn add_local_qlog_mirror_append(&mut self, (): ()) {}
5966
5967 #[inline]
5968 fn add_sql_materializer_apply(&mut self, (): ()) {}
5969
5970 #[inline]
5971 fn record_success(&mut self, _batch_member_count: usize) {}
5972}
5973
5974#[cfg(feature = "sql")]
5975struct EnabledSqlWritePhaseProfile {
5976 observer: SqlWriteProfiler,
5977 service_started: Instant,
5978 commit_lock_wait: Duration,
5979 precheck_classification: Duration,
5980 qwal_prepare: Duration,
5981 consensus_propose: Duration,
5982 local_qlog_mirror_append: Duration,
5983 sql_materializer_apply: Duration,
5984}
5985
5986#[cfg(feature = "sql")]
5987impl EnabledSqlWritePhaseProfile {
5988 fn new(observer: SqlWriteProfiler) -> Self {
5989 Self {
5990 observer,
5991 service_started: Instant::now(),
5992 commit_lock_wait: Duration::ZERO,
5993 precheck_classification: Duration::ZERO,
5994 qwal_prepare: Duration::ZERO,
5995 consensus_propose: Duration::ZERO,
5996 local_qlog_mirror_append: Duration::ZERO,
5997 sql_materializer_apply: Duration::ZERO,
5998 }
5999 }
6000
6001 fn reset(&mut self) {
6002 self.service_started = Instant::now();
6003 self.commit_lock_wait = Duration::ZERO;
6004 self.precheck_classification = Duration::ZERO;
6005 self.qwal_prepare = Duration::ZERO;
6006 self.consensus_propose = Duration::ZERO;
6007 self.local_qlog_mirror_append = Duration::ZERO;
6008 self.sql_materializer_apply = Duration::ZERO;
6009 }
6010}
6011
6012#[cfg(feature = "sql")]
6013impl SqlWritePhaseProfile for EnabledSqlWritePhaseProfile {
6014 type Mark = Instant;
6015
6016 #[inline]
6017 fn mark(&self) -> Instant {
6018 Instant::now()
6019 }
6020
6021 fn commit_lock_acquired(&mut self) {
6022 self.commit_lock_wait = self.service_started.elapsed();
6023 }
6024
6025 fn add_precheck_classification(&mut self, mark: Instant) {
6026 self.precheck_classification += mark.elapsed();
6027 }
6028
6029 fn add_qwal_prepare(&mut self, mark: Instant) {
6030 self.qwal_prepare += mark.elapsed();
6031 }
6032
6033 fn add_consensus_propose(&mut self, mark: Instant) {
6034 self.consensus_propose += mark.elapsed();
6035 }
6036
6037 fn add_local_qlog_mirror_append(&mut self, mark: Instant) {
6038 self.local_qlog_mirror_append += mark.elapsed();
6039 }
6040
6041 fn add_sql_materializer_apply(&mut self, mark: Instant) {
6042 self.sql_materializer_apply += mark.elapsed();
6043 }
6044
6045 fn record_success(&mut self, batch_member_count: usize) {
6046 let total_service_us = duration_as_u64_micros(self.service_started.elapsed());
6047 let commit_lock_wait_us = duration_as_u64_micros(self.commit_lock_wait);
6048 let precheck_classification_us = duration_as_u64_micros(self.precheck_classification);
6049 let qwal_prepare_us = duration_as_u64_micros(self.qwal_prepare);
6050 let consensus_propose_us = duration_as_u64_micros(self.consensus_propose);
6051 let local_qlog_mirror_append_us = duration_as_u64_micros(self.local_qlog_mirror_append);
6052 let sql_materializer_apply_us = duration_as_u64_micros(self.sql_materializer_apply);
6053 let named_us = commit_lock_wait_us
6054 .saturating_add(precheck_classification_us)
6055 .saturating_add(qwal_prepare_us)
6056 .saturating_add(consensus_propose_us)
6057 .saturating_add(local_qlog_mirror_append_us)
6058 .saturating_add(sql_materializer_apply_us);
6059 self.observer.record(SqlWriteProfileSample {
6060 batch_member_count,
6061 commit_lock_wait_us,
6062 precheck_classification_us,
6063 qwal_prepare_us,
6064 consensus_propose_us,
6065 local_qlog_mirror_append_us,
6066 sql_materializer_apply_us,
6067 response_other_total_us: total_service_us.saturating_sub(named_us),
6068 total_service_us,
6069 });
6070 self.reset();
6071 }
6072}
6073
6074#[cfg(feature = "sql")]
6075fn duration_as_u64_micros(duration: Duration) -> u64 {
6076 u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
6077}
6078
6079#[cfg(feature = "sql")]
6080type CheckedSqlRequest = Result<Option<(RequestOutcome, SqlCommandResult)>, NodeError>;
6081
6082#[cfg(feature = "sql")]
6083#[derive(Clone, Debug, Eq, PartialEq)]
6084pub struct VerifiedSnapshotPublication {
6085 anchor: RecoveryAnchor,
6086}
6087
6088impl fmt::Debug for NodeRuntime {
6089 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6090 f.debug_struct("NodeRuntime")
6091 .field("config", &self.config)
6092 .field("ready", &self.ready.load(Ordering::Acquire))
6093 .field("fatal", &self.fatal.load(Ordering::Acquire))
6094 .finish_non_exhaustive()
6095 }
6096}
6097
6098impl NodeRuntime {
6099 pub fn open(
6100 config: NodeConfig,
6101 consensus: Arc<ThreeNodeConsensus>,
6102 peer_candidates: &[&dyn LogPeer],
6103 ) -> Result<Self, NodeError> {
6104 if config.ack_mode == AckMode::DrStrong {
6105 return Err(NodeError::UnsupportedAckMode(AckMode::DrStrong));
6106 }
6107 Materializer::ensure_profile_available(config.execution_profile())?;
6108 fs::create_dir_all(&config.data_dir)
6109 .map_err(|error| NodeError::Storage(error.to_string()))?;
6110 let lock_path = config.data_dir.join(".node.lock");
6111 let data_root_lock = fs::OpenOptions::new()
6112 .read(true)
6113 .write(true)
6114 .create(true)
6115 .truncate(false)
6116 .open(&lock_path)
6117 .map_err(|error| NodeError::Storage(error.to_string()))?;
6118 match data_root_lock.try_lock() {
6119 Ok(()) => {}
6120 Err(fs::TryLockError::WouldBlock) => {
6121 return Err(NodeError::DataRootLocked(config.data_dir.clone()));
6122 }
6123 Err(fs::TryLockError::Error(error)) => {
6124 return Err(NodeError::Storage(error.to_string()));
6125 }
6126 }
6127
6128 let log_store = FileLogStore::open_with_configuration(
6129 config.data_dir.join("consensus/log"),
6130 &config.cluster_id,
6131 config.epoch,
6132 config.log_initial_configuration.clone(),
6133 )
6134 .map_err(|error| NodeError::Storage(error.to_string()))?;
6135 let persisted_configuration = log_store
6136 .configuration_state()
6137 .map_err(|error| NodeError::Storage(error.to_string()))?;
6138 let recovery_anchor = log_store
6139 .logical_state()
6140 .map_err(|error| NodeError::Storage(error.to_string()))?
6141 .anchor;
6142 let materializer =
6143 Materializer::open(&config, &persisted_configuration, recovery_anchor.as_ref())?;
6144 reconcile_local_storage(&config, &log_store, &materializer)?;
6145 recover_peer_candidates(
6146 &config,
6147 consensus.as_ref(),
6148 &log_store,
6149 &materializer,
6150 peer_candidates,
6151 )?;
6152 recover_startup_decisions(&config, consensus.as_ref(), &log_store, &materializer)?;
6153
6154 Ok(Self {
6155 #[cfg(feature = "sql")]
6156 sql_group_commit: SqlGroupCommitQueue::new(config.sql_group_commit_queue_capacity),
6157 #[cfg(feature = "kv")]
6158 kv_group_commit: KvGroupCommitQueue::new(),
6159 config,
6160 consensus,
6161 log_store,
6162 materializer: Mutex::new(materializer),
6163 commit: Mutex::new(()),
6164 read_barriers: ReadBarrierRounds::new(READ_BARRIER_COALESCE_WINDOW),
6165 checkpointing: AtomicBool::new(false),
6166 operation_cancelled: AtomicBool::new(false),
6167 operation_cancelled_notify: tokio::sync::Notify::new(),
6168 ready: AtomicBool::new(true),
6169 fatal: AtomicBool::new(false),
6170 fatal_reason: Mutex::new(None),
6171 #[cfg(test)]
6172 materialized_tip_checks: AtomicUsize::new(0),
6173 #[cfg(all(test, feature = "sql"))]
6174 sql_group_commit_before_execute_hook: None,
6175 #[cfg(all(test, feature = "kv"))]
6176 kv_group_commit_before_execute_hook: None,
6177 #[cfg(all(test, feature = "kv"))]
6178 kv_group_commit_after_execute_hook: None,
6179 _data_root_lock: data_root_lock,
6180 })
6181 }
6182
6183 #[cfg(feature = "sql")]
6184 pub fn write(
6185 &self,
6186 request_id: &str,
6187 key: &str,
6188 value: &str,
6189 ) -> Result<WriteResponse, NodeError> {
6190 let payload = canonical_put(request_id, key, value)?;
6191 let _commit = self.lock_commit()?;
6192 self.execute_put_payload_locked(request_id, key, value, payload)
6193 .map(|outcome| outcome.response)
6194 }
6195
6196 #[cfg(feature = "graph")]
6197 pub fn mutate_graph(&self, command: GraphCommandV1) -> Result<GraphMutationOutcome, NodeError> {
6198 let payload = encode_replicated_graph_command(&command)
6199 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6200 if payload.len() > MAX_COMMAND_BYTES {
6201 return Err(NodeError::InvalidRequest(format!(
6202 "command exceeds {MAX_COMMAND_BYTES} bytes"
6203 )));
6204 }
6205 let _commit = self.lock_commit()?;
6206 self.mutate_graph_payload_locked(&command, payload)
6207 }
6208
6209 #[cfg(feature = "graph")]
6215 #[cfg_attr(
6216 all(not(feature = "sql"), not(feature = "kv")),
6217 allow(unreachable_patterns)
6218 )]
6219 pub fn mutate_graph_batch(
6220 &self,
6221 commands: Vec<GraphCommandV1>,
6222 ) -> Result<Vec<Result<GraphMutationOutcome, NodeError>>, NodeError> {
6223 self.require_execution_profile(ExecutionProfile::Graph)?;
6224 validate_typed_batch_len(commands.len())?;
6225 let mut members = Vec::with_capacity(commands.len());
6226 for command in commands {
6227 let payload = encode_replicated_graph_command(&command)
6228 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6229 validate_command_size(&payload)?;
6230 members.push(RuntimeBatchMember {
6231 #[cfg(feature = "sql")]
6232 request_id: command.request_id().to_owned(),
6233 payload,
6234 operation: QueuedOperation::Graph(command),
6235 });
6236 }
6237 Ok(self
6238 .execute_client_batch(members)
6239 .into_iter()
6240 .map(|result| {
6241 result.and_then(|response| match response {
6242 ClientWriteResponse::Graph(outcome) => Ok(outcome),
6243 _ => Err(NodeError::Invariant(
6244 "graph batch returned a response for another profile".into(),
6245 )),
6246 })
6247 })
6248 .collect())
6249 }
6250
6251 #[cfg(feature = "graph")]
6252 fn mutate_graph_payload_locked(
6253 &self,
6254 command: &GraphCommandV1,
6255 payload: Vec<u8>,
6256 ) -> Result<GraphMutationOutcome, NodeError> {
6257 self.ensure_ready()?;
6258 self.ensure_writes_active()?;
6259 if let Some(record) = self.check_graph_request(command.request_id(), &payload)? {
6260 return Ok(GraphMutationOutcome::from_record(record));
6261 }
6262 loop {
6263 let (last_index, last_hash) = self.ensure_materialized_tip()?;
6264 let slot = last_index.checked_add(1).ok_or_else(|| {
6265 self.latch(NodeError::Invariant("qlog index is exhausted".into()))
6266 })?;
6267 let entry = self
6268 .consensus
6269 .propose_at_cancellable(
6270 slot,
6271 last_hash,
6272 Command::new(CommandKind::Deterministic, payload.clone()),
6273 &self.operation_cancelled,
6274 )
6275 .map_err(|error| self.map_consensus_error(error))?;
6276 self.persist_entry(&entry, slot, last_hash)?;
6277 if let Some(record) = self.check_graph_request(command.request_id(), &payload)? {
6278 return Ok(GraphMutationOutcome::from_record(record));
6279 }
6280 if entry.entry_type == EntryType::Command && entry.payload == payload {
6281 return Err(self.latch(NodeError::Invariant(
6282 "committed graph request was not recorded by Ladybug".into(),
6283 )));
6284 }
6285 }
6286 }
6287
6288 #[cfg(feature = "graph")]
6289 pub fn get_graph_document(
6290 &self,
6291 id: &str,
6292 consistency: ReadConsistency,
6293 ) -> Result<GraphReadResponse, NodeError> {
6294 match consistency {
6295 ReadConsistency::Local => self.get_graph_document_local(id, None),
6296 ReadConsistency::AppliedIndex(required) => {
6297 self.get_graph_document_local(id, Some(required))
6298 }
6299 ReadConsistency::ReadBarrier => {
6300 let anchor = self.establish_read_barrier()?;
6301 self.validate_read_barrier_before_snapshot(anchor)?;
6302 let response = self.get_graph_document_local(id, Some(anchor.index()))?;
6303 self.validate_read_barrier_snapshot(
6304 anchor,
6305 LogAnchor::new(response.applied_index, response.hash),
6306 )?;
6307 Ok(response)
6308 }
6309 }
6310 }
6311
6312 #[cfg(feature = "graph")]
6313 pub fn query_graph(
6314 &self,
6315 statement: &str,
6316 parameters: &BTreeMap<String, GraphParameterValue>,
6317 consistency: ReadConsistency,
6318 max_rows: u32,
6319 ) -> Result<GraphQueryResult, NodeError> {
6320 if max_rows == 0 || max_rows > MAX_GRAPH_MAX_ROWS {
6321 return Err(NodeError::InvalidRequest(format!(
6322 "max_rows must be between 1 and {MAX_GRAPH_MAX_ROWS}"
6323 )));
6324 }
6325 match consistency {
6326 ReadConsistency::Local => self.query_graph_local(statement, parameters, None, max_rows),
6327 ReadConsistency::AppliedIndex(required) => {
6328 self.query_graph_local(statement, parameters, Some(required), max_rows)
6329 }
6330 ReadConsistency::ReadBarrier => {
6331 let anchor = self.establish_read_barrier()?;
6332 self.validate_read_barrier_before_snapshot(anchor)?;
6333 let result =
6334 self.query_graph_local(statement, parameters, Some(anchor.index()), max_rows)?;
6335 self.validate_read_barrier_snapshot(
6336 anchor,
6337 LogAnchor::new(result.applied_index, result.hash),
6338 )?;
6339 Ok(result)
6340 }
6341 }
6342 }
6343
6344 #[cfg(feature = "kv")]
6345 pub fn mutate_kv(&self, command: KvCommandV1) -> Result<KvMutationOutcome, NodeError> {
6346 self.mutate_kv_batch(vec![command])?
6347 .into_iter()
6348 .next()
6349 .expect("one-member KV batch returns one result")
6350 }
6351
6352 #[cfg(feature = "kv")]
6358 #[cfg_attr(
6359 all(not(feature = "sql"), not(feature = "graph")),
6360 allow(unreachable_patterns)
6361 )]
6362 pub fn mutate_kv_batch(
6363 &self,
6364 commands: Vec<KvCommandV1>,
6365 ) -> Result<Vec<Result<KvMutationOutcome, NodeError>>, NodeError> {
6366 self.require_execution_profile(ExecutionProfile::Kv)?;
6367 validate_kv_batch_len(commands.len())?;
6368 let mut members = Vec::with_capacity(commands.len());
6369 for command in commands {
6370 let payload = encode_replicated_kv_command(&command)
6371 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
6372 validate_command_size(&payload)?;
6373 members.push(RuntimeBatchMember {
6374 #[cfg(feature = "sql")]
6375 request_id: command.request_id().to_owned(),
6376 payload,
6377 operation: QueuedOperation::Kv(command),
6378 });
6379 }
6380 Ok(self
6381 .execute_kv_group_commit(members)?
6382 .into_iter()
6383 .map(|result| {
6384 result.and_then(|response| match response {
6385 ClientWriteResponse::Kv(outcome) => Ok(outcome),
6386 _ => Err(NodeError::Invariant(
6387 "KV batch returned a response for another profile".into(),
6388 )),
6389 })
6390 })
6391 .collect())
6392 }
6393
6394 #[cfg(feature = "kv")]
6395 fn mutate_kv_payload_locked(
6396 &self,
6397 command: &KvCommandV1,
6398 payload: Vec<u8>,
6399 ) -> Result<KvMutationOutcome, NodeError> {
6400 self.ensure_ready()?;
6401 self.ensure_writes_active()?;
6402 if let Some(record) = self.check_kv_request(command.request_id(), &payload)? {
6403 return Ok(KvMutationOutcome::from_record(record));
6404 }
6405 loop {
6406 let (last_index, last_hash) = self.ensure_materialized_tip()?;
6407 let slot = last_index.checked_add(1).ok_or_else(|| {
6408 self.latch(NodeError::Invariant("qlog index is exhausted".into()))
6409 })?;
6410 let entry = self
6411 .consensus
6412 .propose_at_cancellable(
6413 slot,
6414 last_hash,
6415 Command::new(CommandKind::Deterministic, payload.clone()),
6416 &self.operation_cancelled,
6417 )
6418 .map_err(|error| self.map_consensus_error(error))?;
6419 self.persist_entry(&entry, slot, last_hash)?;
6420 if let Some(record) = self.check_kv_request(command.request_id(), &payload)? {
6421 return Ok(KvMutationOutcome::from_record(record));
6422 }
6423 if entry.entry_type == EntryType::Command && entry.payload == payload {
6424 return Err(self.latch(NodeError::Invariant(
6425 "committed KV request was not recorded by redb".into(),
6426 )));
6427 }
6428 }
6429 }
6430
6431 #[cfg(feature = "kv")]
6432 pub fn get_kv(
6433 &self,
6434 key: &[u8],
6435 consistency: ReadConsistency,
6436 ) -> Result<KvReadResponse, NodeError> {
6437 match consistency {
6438 ReadConsistency::Local => self.get_kv_local(key, None),
6439 ReadConsistency::AppliedIndex(required) => self.get_kv_local(key, Some(required)),
6440 ReadConsistency::ReadBarrier => {
6441 let anchor = self.establish_read_barrier()?;
6442 self.validate_read_barrier_before_snapshot(anchor)?;
6443 let response = self.get_kv_local(key, Some(anchor.index()))?;
6444 self.validate_read_barrier_snapshot(
6445 anchor,
6446 LogAnchor::new(response.applied_index, response.hash),
6447 )?;
6448 Ok(response)
6449 }
6450 }
6451 }
6452
6453 #[cfg(feature = "kv")]
6454 pub fn scan_kv_range(
6455 &self,
6456 start: &[u8],
6457 end: Option<&[u8]>,
6458 limit: usize,
6459 cursor: Option<&[u8]>,
6460 consistency: ReadConsistency,
6461 ) -> Result<KvScanResult, NodeError> {
6462 match consistency {
6463 ReadConsistency::Local => self.scan_kv_range_local(start, end, limit, cursor, None),
6464 ReadConsistency::AppliedIndex(required) => {
6465 self.scan_kv_range_local(start, end, limit, cursor, Some(required))
6466 }
6467 ReadConsistency::ReadBarrier => {
6468 let anchor = self.establish_read_barrier()?;
6469 self.validate_read_barrier_before_snapshot(anchor)?;
6470 let result =
6471 self.scan_kv_range_local(start, end, limit, cursor, Some(anchor.index()))?;
6472 self.validate_read_barrier_snapshot(
6473 anchor,
6474 LogAnchor::new(result.tip().applied_index(), result.tip().applied_hash()),
6475 )?;
6476 Ok(result)
6477 }
6478 }
6479 }
6480
6481 #[cfg(feature = "kv")]
6482 pub fn scan_kv_prefix(
6483 &self,
6484 prefix: &[u8],
6485 limit: usize,
6486 cursor: Option<&[u8]>,
6487 consistency: ReadConsistency,
6488 ) -> Result<KvScanResult, NodeError> {
6489 match consistency {
6490 ReadConsistency::Local => self.scan_kv_prefix_local(prefix, limit, cursor, None),
6491 ReadConsistency::AppliedIndex(required) => {
6492 self.scan_kv_prefix_local(prefix, limit, cursor, Some(required))
6493 }
6494 ReadConsistency::ReadBarrier => {
6495 let anchor = self.establish_read_barrier()?;
6496 self.validate_read_barrier_before_snapshot(anchor)?;
6497 let result =
6498 self.scan_kv_prefix_local(prefix, limit, cursor, Some(anchor.index()))?;
6499 self.validate_read_barrier_snapshot(
6500 anchor,
6501 LogAnchor::new(result.tip().applied_index(), result.tip().applied_hash()),
6502 )?;
6503 Ok(result)
6504 }
6505 }
6506 }
6507
6508 #[cfg(feature = "sql")]
6509 pub fn execute_sql(&self, command: SqlCommand) -> Result<WriteResponse, NodeError> {
6510 self.execute_sql_with_results(command)
6511 .map(|response| WriteResponse {
6512 applied_index: response.applied_index,
6513 hash: response.hash,
6514 })
6515 }
6516
6517 #[cfg(feature = "sql")]
6525 pub fn execute_sql_batch(
6526 &self,
6527 commands: Vec<SqlCommand>,
6528 ) -> Result<Vec<Result<SqlExecuteResponse, NodeError>>, NodeError> {
6529 self.require_execution_profile(ExecutionProfile::Sqlite)?;
6530 validate_sql_batch_len(commands.len())?;
6531 let mut members = Vec::with_capacity(commands.len());
6532 let mut aggregate_encoded_bytes = 0_usize;
6533 for command in commands {
6534 validate_field(
6535 "request_id",
6536 &command.request_id,
6537 MAX_REQUEST_ID_BYTES,
6538 false,
6539 )?;
6540 let payload = encode_sql_command_with_index(&command)?;
6541 validate_command_size(&payload)?;
6542 aggregate_encoded_bytes = aggregate_encoded_bytes.saturating_add(payload.len());
6543 if aggregate_encoded_bytes > MAX_COMMAND_BYTES {
6544 return Err(NodeError::ResourceExhausted(format!(
6545 "SQL write batch exceeds {MAX_COMMAND_BYTES} aggregate encoded bytes"
6546 )));
6547 }
6548 members.push(RuntimeBatchMember {
6549 request_id: command.request_id.clone(),
6550 payload,
6551 operation: QueuedOperation::Sql(command),
6552 });
6553 }
6554 let single_statement_batch = members.iter().all(|member| {
6555 matches!(
6556 &member.operation,
6557 QueuedOperation::Sql(command) if command.statements.len() == 1
6558 )
6559 });
6560 let results = if single_statement_batch {
6561 self.execute_sql_group_commit(members)?
6562 } else {
6563 self.execute_client_batch(members)
6564 };
6565 Ok(results
6566 .into_iter()
6567 .map(|result| {
6568 result.and_then(|response| match response {
6569 ClientWriteResponse::Sql(response) => Ok(response),
6570 _ => Err(NodeError::Invariant(
6571 "SQL batch returned a response for another profile".into(),
6572 )),
6573 })
6574 })
6575 .collect())
6576 }
6577
6578 #[cfg(feature = "sql")]
6579 fn execute_sql_with_results(
6580 &self,
6581 command: SqlCommand,
6582 ) -> Result<SqlExecuteResponse, NodeError> {
6583 self.execute_sql_batch(vec![command])?
6584 .into_iter()
6585 .next()
6586 .expect("one-member SQL batch returns one result")
6587 }
6588
6589 #[cfg(feature = "sql")]
6590 fn execute_sql_group_commit(&self, members: Vec<RuntimeBatchMember>) -> SqlGroupCommitResult {
6591 let (job, leader) = self
6592 .sql_group_commit
6593 .enqueue(members, &self.operation_cancelled)?;
6594 if leader {
6595 if let Some(observer) = self.config.sql_write_profiler.clone() {
6596 self.run_sql_group_commit_leader(&mut EnabledSqlWritePhaseProfile::new(observer));
6597 } else {
6598 self.run_sql_group_commit_leader(&mut DisabledSqlWritePhaseProfile);
6599 }
6600 }
6601 job.wait(&self.operation_cancelled)
6602 }
6603
6604 #[cfg(feature = "sql")]
6605 fn run_sql_group_commit_leader<P: SqlWritePhaseProfile>(&self, profile: &mut P) {
6606 let _commit = match self.lock_commit() {
6607 Ok(commit) => commit,
6608 Err(error) => {
6609 self.sql_group_commit.fail_pending(error);
6610 return;
6611 }
6612 };
6613 profile.commit_lock_acquired();
6614
6615 loop {
6616 if !self
6617 .sql_group_commit
6618 .collect_until_full_or_timeout(self.config.writer_batch_window())
6619 {
6620 break;
6621 }
6622 let Some(jobs) = self.sql_group_commit.drain_next_group() else {
6623 break;
6624 };
6625 if let Err(error) = self
6626 .ensure_ready()
6627 .and_then(|_| self.ensure_writes_active())
6628 {
6629 for job in &jobs {
6630 job.publish(Err(error.clone()));
6631 }
6632 self.sql_group_commit.fail_pending(error);
6633 return;
6634 }
6635 let execution = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
6636 #[cfg(test)]
6637 if let Some(hook) = &self.sql_group_commit_before_execute_hook {
6638 hook();
6639 }
6640 self.execute_sql_group_locked(&jobs, profile)
6641 }));
6642 let grouped_results = match execution {
6643 Ok(Ok(grouped_results)) => grouped_results,
6644 Ok(Err(error)) => {
6645 let error = if matches!(error, NodeError::Unavailable(_)) {
6646 error
6647 } else {
6648 self.latch(error)
6649 };
6650 for job in &jobs {
6651 job.publish(Err(error.clone()));
6652 }
6653 self.sql_group_commit.fail_pending(error);
6654 return;
6655 }
6656 Err(_) => {
6657 let error =
6658 self.latch(NodeError::Fatal("SQL group commit leader panicked".into()));
6659 for job in &jobs {
6660 job.publish(Err(error.clone()));
6661 }
6662 self.sql_group_commit.fail_pending(error);
6663 return;
6664 }
6665 };
6666 for (job, results) in jobs.iter().zip(grouped_results) {
6667 job.publish(Ok(results));
6668 }
6669 }
6670 }
6671
6672 #[cfg(feature = "sql")]
6673 fn execute_sql_group_locked<P: SqlWritePhaseProfile>(
6674 &self,
6675 jobs: &[Arc<SqlGroupCommitJob>],
6676 profile: &mut P,
6677 ) -> Result<Vec<Vec<Result<ClientWriteResponse, NodeError>>>, NodeError> {
6678 if self.operation_cancelled.load(Ordering::Acquire) {
6679 return Err(NodeError::Unavailable(
6680 "SQL group commit cancelled during shutdown".into(),
6681 ));
6682 }
6683 let total_members = jobs.iter().map(|job| job.member_count).sum();
6684 if total_members == 0 || total_members > MAX_SQL_WRITE_BATCH_MEMBERS {
6685 return Err(NodeError::Invariant(format!(
6686 "SQL group commit drained {total_members} members outside 1..={MAX_SQL_WRITE_BATCH_MEMBERS}"
6687 )));
6688 }
6689 let mut members = Vec::with_capacity(total_members);
6690 for job in jobs {
6691 let job_members = job.take_members()?;
6692 if job_members.len() != job.member_count {
6693 return Err(NodeError::Invariant(
6694 "SQL group commit job member count changed while queued".into(),
6695 ));
6696 }
6697 members.extend(job_members);
6698 }
6699 let results = self.execute_sql_client_batch_locked(&members, profile);
6700 if self.operation_cancelled.load(Ordering::Acquire) {
6701 return Err(NodeError::Unavailable(
6702 "SQL group commit cancelled during shutdown".into(),
6703 ));
6704 }
6705 if self.is_fatal() {
6706 return Err(NodeError::Fatal(
6707 self.fatal_reason()
6708 .unwrap_or_else(|| "SQL group commit failed fatally".into()),
6709 ));
6710 }
6711 if results.len() != total_members {
6712 return Err(NodeError::Invariant(format!(
6713 "SQL group commit returned {} results for {total_members} members",
6714 results.len()
6715 )));
6716 }
6717 let mut results = results.into_iter();
6718 let grouped = jobs
6719 .iter()
6720 .map(|job| results.by_ref().take(job.member_count).collect::<Vec<_>>())
6721 .collect::<Vec<_>>();
6722 if results.next().is_some() || grouped.iter().map(Vec::len).sum::<usize>() != total_members
6723 {
6724 return Err(NodeError::Invariant(
6725 "SQL group commit result offsets were misaligned".into(),
6726 ));
6727 }
6728 Ok(grouped)
6729 }
6730
6731 #[cfg(feature = "sql")]
6732 fn execute_client_batch(
6733 &self,
6734 members: Vec<RuntimeBatchMember>,
6735 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
6736 if self.config.execution_profile != ExecutionProfile::Sqlite {
6737 return self.execute_profile_client_batch(members);
6738 }
6739 let is_single_statement_sql = members.iter().all(|member| {
6740 matches!(
6741 &member.operation,
6742 QueuedOperation::Sql(command) if command.statements.len() == 1
6743 )
6744 });
6745 if is_single_statement_sql {
6746 if let Some(observer) = self.config.sql_write_profiler.clone() {
6747 let mut profile = EnabledSqlWritePhaseProfile::new(observer);
6748 let _commit = match self.lock_commit() {
6749 Ok(commit) => commit,
6750 Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
6751 };
6752 profile.commit_lock_acquired();
6753 if let Err(error) = self
6754 .ensure_ready()
6755 .and_then(|_| self.ensure_writes_active())
6756 {
6757 return members.into_iter().map(|_| Err(error.clone())).collect();
6758 }
6759 return self.execute_sql_client_batch_locked(&members, &mut profile);
6760 }
6761 }
6762 let _commit = match self.lock_commit() {
6763 Ok(commit) => commit,
6764 Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
6765 };
6766 if let Err(error) = self
6767 .ensure_ready()
6768 .and_then(|_| self.ensure_writes_active())
6769 {
6770 return members.into_iter().map(|_| Err(error.clone())).collect();
6771 }
6772
6773 if is_single_statement_sql {
6774 return self
6775 .execute_sql_client_batch_locked(&members, &mut DisabledSqlWritePhaseProfile);
6776 }
6777
6778 members
6779 .iter()
6780 .map(|member| self.execute_single_member_locked(member))
6781 .collect()
6782 }
6783
6784 #[cfg(feature = "sql")]
6785 fn execute_sql_client_batch_locked<P: SqlWritePhaseProfile>(
6786 &self,
6787 members: &[RuntimeBatchMember],
6788 profile: &mut P,
6789 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
6790 let classification_mark = profile.mark();
6791 let mut results = vec![None; members.len()];
6792 let mut pending = Vec::new();
6793 let mut canonical_by_request: HashMap<String, Vec<usize>> = HashMap::new();
6794 let mut lookup_indices = Vec::with_capacity(members.len());
6795 let mut aliases = vec![None; members.len()];
6796 let mut blocked_by = vec![None; members.len()];
6797
6798 for (index, member) in members.iter().enumerate() {
6799 let QueuedOperation::Sql(command) = &member.operation else {
6800 unreachable!("SQL batch members were validated by the caller");
6801 };
6802 let canonicals = canonical_by_request
6803 .entry(command.request_id.clone())
6804 .or_default();
6805 if let Some(canonical) = canonicals
6806 .iter()
6807 .copied()
6808 .find(|canonical| members[*canonical].payload == member.payload)
6809 {
6810 aliases[index] = Some(canonical);
6811 continue;
6812 }
6813 blocked_by[index] = canonicals.last().copied();
6814 canonicals.push(index);
6815 lookup_indices.push(index);
6816 }
6817
6818 let preflight = self.check_sql_members_bulk(members, &lookup_indices);
6819 let preflight = match preflight {
6820 Ok(preflight) => preflight,
6821 Err(error) => {
6822 profile.add_precheck_classification(classification_mark);
6823 return members.iter().map(|_| Err(error.clone())).collect();
6824 }
6825 };
6826 for (index, lookup) in preflight {
6827 match lookup {
6828 Ok(Some((outcome, sql_result))) => {
6829 results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
6830 write_response(outcome),
6831 Some(sql_result),
6832 ))));
6833 }
6834 Ok(None) => pending.push(index),
6835 Err(error) => results[index] = Some(Err(error)),
6836 }
6837 }
6838 profile.add_precheck_classification(classification_mark);
6839
6840 while !pending.is_empty() {
6841 let eligible = pending
6842 .iter()
6843 .copied()
6844 .filter(|index| {
6845 blocked_by[*index].is_none_or(|predecessor| results[predecessor].is_some())
6846 })
6847 .take(MAX_SQL_WRITE_BATCH_MEMBERS)
6848 .collect::<Vec<_>>();
6849 if eligible.is_empty() {
6850 let error = self.latch(NodeError::Invariant(
6851 "SQL writer batch has an unresolved duplicate dependency".into(),
6852 ));
6853 for index in pending.drain(..) {
6854 results[index] = Some(Err(error.clone()));
6855 }
6856 break;
6857 }
6858
6859 let (last_index, last_hash) = match self.ensure_materialized_tip() {
6860 Ok(tip) => tip,
6861 Err(error) => {
6862 for index in pending.drain(..) {
6863 results[index] = Some(Err(error.clone()));
6864 }
6865 break;
6866 }
6867 };
6868 let mut attempt_count = eligible.len();
6869 let (proposal_payload, prepared_results) = loop {
6870 let attempted = &eligible[..attempt_count];
6871 let batch_members = attempted
6872 .iter()
6873 .map(|index| {
6874 let QueuedOperation::Sql(command) = &members[*index].operation else {
6875 unreachable!("SQL batch members were validated above");
6876 };
6877 SqlBatchMember {
6878 command,
6879 request_payload: &members[*index].payload,
6880 }
6881 })
6882 .collect::<Vec<_>>();
6883 let preparation_mark = profile.mark();
6884 let preparation = self.lock_sqlite().and_then(|sqlite| {
6885 sqlite
6886 .prepare_sql_batch_effect(&batch_members, last_index, last_hash)
6887 .map_err(|error| self.map_sqlite_error(error))
6888 });
6889 profile.add_qwal_prepare(preparation_mark);
6890 let preparation = match preparation {
6891 Ok(preparation) => preparation,
6892 Err(NodeError::ResourceExhausted(message)) if attempt_count > 1 => {
6893 attempt_count = attempt_count.div_ceil(2);
6894 let _ = message;
6895 continue;
6896 }
6897 Err(NodeError::ResourceExhausted(message)) => {
6898 results[attempted[0]] = Some(Err(NodeError::ResourceExhausted(message)));
6899 break (None, Vec::new());
6900 }
6901 Err(error) => {
6902 for index in pending.drain(..) {
6903 results[index] = Some(Err(error.clone()));
6904 }
6905 break (None, Vec::new());
6906 }
6907 };
6908 if preparation.results.len() != attempted.len() {
6909 let error = self.latch(NodeError::Invariant(
6910 "SQLite batch preparation returned a misaligned result vector".into(),
6911 ));
6912 for index in pending.drain(..) {
6913 results[index] = Some(Err(error.clone()));
6914 }
6915 break (None, Vec::new());
6916 }
6917
6918 let mut proposed = Vec::new();
6919 for (index, member_result) in attempted.iter().copied().zip(preparation.results) {
6920 match member_result {
6921 Ok(result) => proposed.push((index, result)),
6922 Err(error) => {
6923 results[index] = Some(Err(self.map_sql_batch_member_error(error)))
6924 }
6925 }
6926 }
6927 match preparation.effect {
6928 Some(_) if proposed.is_empty() => {
6929 let error = self.latch(NodeError::Invariant(
6930 "SQLite prepared an effect without a successful SQL member".into(),
6931 ));
6932 for index in pending.drain(..) {
6933 results[index] = Some(Err(error.clone()));
6934 }
6935 break (None, Vec::new());
6936 }
6937 Some(payload) if !payload.starts_with(QWAL_V3_MAGIC) => {
6938 let error = self.latch(NodeError::Invariant(
6939 "SQLite materializer prepared a non-QWAL v3 SQL batch".into(),
6940 ));
6941 for index in pending.drain(..) {
6942 results[index] = Some(Err(error.clone()));
6943 }
6944 break (None, Vec::new());
6945 }
6946 Some(payload) if payload.len() <= MAX_COMMAND_BYTES => {
6947 break (Some(payload), proposed)
6948 }
6949 Some(_) if attempt_count > 1 => {
6950 for index in attempted {
6951 results[*index] = None;
6952 }
6953 attempt_count = attempt_count.div_ceil(2);
6954 }
6955 Some(_) => {
6956 results[attempted[0]] = Some(Err(NodeError::ResourceExhausted(format!(
6957 "SQL effect exceeds {MAX_COMMAND_BYTES} bytes"
6958 ))));
6959 break (None, Vec::new());
6960 }
6961 None if proposed.is_empty() => break (None, Vec::new()),
6962 None => {
6963 let error = self.latch(NodeError::Invariant(
6964 "SQLite omitted the effect for successful SQL members".into(),
6965 ));
6966 for index in pending.drain(..) {
6967 results[index] = Some(Err(error.clone()));
6968 }
6969 break (None, Vec::new());
6970 }
6971 }
6972 };
6973
6974 pending.retain(|index| results[*index].is_none());
6975 let Some(proposal_payload) = proposal_payload else {
6976 continue;
6977 };
6978 let slot = match last_index.checked_add(1) {
6979 Some(slot) => slot,
6980 None => {
6981 let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
6982 for index in pending.drain(..) {
6983 results[index] = Some(Err(error.clone()));
6984 }
6985 break;
6986 }
6987 };
6988 let consensus_mark = profile.mark();
6989 let entry = self.consensus.propose_at_cancellable(
6990 slot,
6991 last_hash,
6992 Command::new(CommandKind::Deterministic, proposal_payload.clone()),
6993 &self.operation_cancelled,
6994 );
6995 profile.add_consensus_propose(consensus_mark);
6996 let entry = match entry {
6997 Ok(entry) => entry,
6998 Err(error) => {
6999 let error = self.map_consensus_error(error);
7000 for index in pending.drain(..) {
7001 results[index] = Some(Err(error.clone()));
7002 }
7003 break;
7004 }
7005 };
7006 if let Err(error) = self.persist_sql_entry_profiled(&entry, slot, last_hash, profile) {
7007 for index in pending.drain(..) {
7008 results[index] = Some(Err(error.clone()));
7009 }
7010 break;
7011 }
7012
7013 let exact_winner =
7014 entry.entry_type == EntryType::Command && entry.payload == proposal_payload;
7015 let exact_winner_member_count = exact_winner.then_some(prepared_results.len());
7016 if exact_winner {
7017 for (index, sql_result) in prepared_results {
7018 results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7019 WriteResponse {
7020 applied_index: entry.index,
7021 hash: entry.hash,
7022 },
7023 Some(sql_result),
7024 ))));
7025 }
7026 pending.retain(|index| results[*index].is_none());
7027 }
7028
7029 let current_pending = std::mem::take(&mut pending);
7030 let classification_mark = profile.mark();
7031 let post_commit = self.check_sql_members_bulk(members, ¤t_pending);
7032 let post_commit = match post_commit {
7033 Ok(post_commit) => post_commit,
7034 Err(error) => {
7035 for index in current_pending {
7036 results[index] = Some(Err(error.clone()));
7037 }
7038 profile.add_precheck_classification(classification_mark);
7039 if let Some(batch_member_count) = exact_winner_member_count {
7040 profile.record_success(batch_member_count);
7041 }
7042 break;
7043 }
7044 };
7045 for (index, lookup) in post_commit {
7046 match lookup {
7047 Ok(Some((outcome, sql_result))) => {
7048 results[index] = Some(Ok(ClientWriteResponse::Sql(sql_execute_response(
7049 write_response(outcome),
7050 Some(sql_result),
7051 ))));
7052 }
7053 Ok(None) => pending.push(index),
7054 Err(error) => results[index] = Some(Err(error)),
7055 }
7056 }
7057 profile.add_precheck_classification(classification_mark);
7058 if let Some(batch_member_count) = exact_winner_member_count {
7059 profile.record_success(batch_member_count);
7060 }
7061 }
7062
7063 for (index, canonical) in aliases.into_iter().enumerate() {
7064 if let Some(canonical) = canonical {
7065 results[index] = results[canonical].clone();
7066 }
7067 }
7068 results
7069 .into_iter()
7070 .map(|result| {
7071 result.unwrap_or_else(|| {
7072 Err(self.latch(NodeError::Invariant(
7073 "SQL writer batch omitted a request result".into(),
7074 )))
7075 })
7076 })
7077 .collect()
7078 }
7079
7080 #[cfg(feature = "sql")]
7081 fn check_sql_members_bulk(
7082 &self,
7083 members: &[RuntimeBatchMember],
7084 indices: &[usize],
7085 ) -> Result<Vec<(usize, CheckedSqlRequest)>, NodeError> {
7086 if indices.is_empty() {
7087 return Ok(Vec::new());
7088 }
7089 let mut rounds = Vec::<Vec<usize>>::new();
7090 let mut occurrence_by_request = HashMap::<String, usize>::new();
7091 for index in indices {
7092 let QueuedOperation::Sql(command) = &members[*index].operation else {
7093 return Err(self.latch(NodeError::Invariant(
7094 "non-SQL member reached SQL receipt precheck".into(),
7095 )));
7096 };
7097 let round = occurrence_by_request
7098 .entry(command.request_id.clone())
7099 .or_default();
7100 if rounds.len() == *round {
7101 rounds.push(Vec::new());
7102 }
7103 rounds[*round].push(*index);
7104 *round += 1;
7105 }
7106
7107 let sqlite = self.lock_sqlite()?;
7108 let mut checked = Vec::with_capacity(indices.len());
7109 for round in rounds {
7110 let requests = round
7111 .iter()
7112 .map(|index| {
7113 let QueuedOperation::Sql(command) = &members[*index].operation else {
7114 unreachable!("SQL receipt round contains only SQL members");
7115 };
7116 (
7117 command.request_id.as_str(),
7118 members[*index].payload.as_slice(),
7119 )
7120 })
7121 .collect::<Vec<_>>();
7122 let lookups = sqlite
7123 .check_sql_requests(&requests)
7124 .map_err(|error| self.map_sqlite_error(error))?;
7125 if lookups.len() != round.len() {
7126 return Err(self.latch(NodeError::Invariant(
7127 "SQLite bulk receipt precheck returned a misaligned result vector".into(),
7128 )));
7129 }
7130 for (index, lookup) in round.into_iter().zip(lookups) {
7131 checked.push((
7132 index,
7133 match lookup {
7134 Ok(Some((outcome, Some(sql_result)))) => Ok(Some((outcome, sql_result))),
7135 Ok(Some((_, None))) => Err(self.latch(NodeError::Invariant(
7136 "stored SQL receipt omitted its command result".into(),
7137 ))),
7138 Ok(None) => Ok(None),
7139 Err(error) => Err(self.map_sql_batch_member_error(error)),
7140 },
7141 ));
7142 }
7143 }
7144 Ok(checked)
7145 }
7146
7147 #[cfg(feature = "kv")]
7148 fn execute_kv_group_commit(&self, members: Vec<RuntimeBatchMember>) -> KvGroupCommitResult {
7149 let (job, leader) = self
7150 .kv_group_commit
7151 .enqueue(members, &self.operation_cancelled)?;
7152 if leader {
7153 self.run_kv_group_commit_leader();
7154 }
7155 job.wait(&self.operation_cancelled)
7156 }
7157
7158 #[cfg(feature = "kv")]
7159 fn run_kv_group_commit_leader(&self) {
7160 let _commit = match self.lock_commit() {
7161 Ok(commit) => commit,
7162 Err(error) => {
7163 self.kv_group_commit.fail_pending(error);
7164 return;
7165 }
7166 };
7167
7168 loop {
7169 if !self
7170 .kv_group_commit
7171 .collect_until_full_or_timeout(self.config.writer_batch_window())
7172 {
7173 break;
7174 }
7175 let Some(jobs) = self.kv_group_commit.drain_next_group() else {
7176 break;
7177 };
7178 if let Err(error) = self
7179 .ensure_ready()
7180 .and_then(|_| self.ensure_writes_active())
7181 {
7182 for job in &jobs {
7183 job.publish(Err(error.clone()));
7184 }
7185 self.kv_group_commit.fail_pending(error);
7186 return;
7187 }
7188 let execution = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7189 #[cfg(test)]
7190 if let Some(hook) = &self.kv_group_commit_before_execute_hook {
7191 hook();
7192 }
7193 self.execute_kv_group_locked(&jobs)
7194 }));
7195 let grouped_results = match execution {
7196 Ok(Ok(grouped_results)) => grouped_results,
7197 Ok(Err(error)) => {
7198 let error = if matches!(error, NodeError::Unavailable(_)) {
7199 error
7200 } else {
7201 self.latch(error)
7202 };
7203 for job in &jobs {
7204 job.publish(Err(error.clone()));
7205 }
7206 self.kv_group_commit.fail_pending(error);
7207 return;
7208 }
7209 Err(_) => {
7210 let error =
7211 self.latch(NodeError::Fatal("KV group commit leader panicked".into()));
7212 for job in &jobs {
7213 job.publish(Err(error.clone()));
7214 }
7215 self.kv_group_commit.fail_pending(error);
7216 return;
7217 }
7218 };
7219 for (job, results) in jobs.iter().zip(grouped_results) {
7220 job.publish(Ok(results));
7221 }
7222 }
7223 }
7224
7225 #[cfg(feature = "kv")]
7226 fn execute_kv_group_locked(
7227 &self,
7228 jobs: &[Arc<KvGroupCommitJob>],
7229 ) -> Result<Vec<Vec<Result<ClientWriteResponse, NodeError>>>, NodeError> {
7230 if self.operation_cancelled.load(Ordering::Acquire) {
7231 return Err(NodeError::Unavailable(
7232 "KV group commit cancelled during shutdown".into(),
7233 ));
7234 }
7235 let total_members = jobs.iter().map(|job| job.member_count).sum();
7236 if total_members == 0 || total_members > MAX_KV_GROUP_COMMIT_MEMBERS {
7237 return Err(NodeError::Invariant(format!(
7238 "KV group commit drained {total_members} members outside 1..={MAX_KV_GROUP_COMMIT_MEMBERS}"
7239 )));
7240 }
7241 let mut members = Vec::with_capacity(total_members);
7242 for job in jobs {
7243 let job_members = job.take_members()?;
7244 if job_members.len() != job.member_count {
7245 return Err(NodeError::Invariant(
7246 "KV group commit job member count changed while queued".into(),
7247 ));
7248 }
7249 members.extend(job_members);
7250 }
7251 let results = self.execute_kv_client_batch_locked(&members);
7252 #[cfg(test)]
7253 if let Some(hook) = &self.kv_group_commit_after_execute_hook {
7254 hook(self);
7255 }
7256 if results.len() != total_members {
7257 let error = self.latch(NodeError::Invariant(format!(
7258 "KV group commit returned {} results for {total_members} members",
7259 results.len()
7260 )));
7261 return Ok(jobs
7262 .iter()
7263 .map(|job| vec![Err(error.clone()); job.member_count])
7264 .collect());
7265 }
7266 let mut results = results.into_iter();
7267 let grouped = jobs
7268 .iter()
7269 .map(|job| results.by_ref().take(job.member_count).collect::<Vec<_>>())
7270 .collect::<Vec<_>>();
7271 debug_assert!(results.next().is_none());
7272 debug_assert_eq!(grouped.iter().map(Vec::len).sum::<usize>(), total_members);
7273 Ok(grouped)
7274 }
7275
7276 #[cfg(not(feature = "sql"))]
7277 fn execute_client_batch(
7278 &self,
7279 members: Vec<RuntimeBatchMember>,
7280 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7281 self.execute_profile_client_batch(members)
7282 }
7283
7284 fn execute_profile_client_batch(
7285 &self,
7286 members: Vec<RuntimeBatchMember>,
7287 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7288 #[cfg(feature = "graph")]
7289 if self.config.execution_profile == ExecutionProfile::Graph {
7290 return self.execute_graph_client_batch(members);
7291 }
7292 #[cfg(feature = "kv")]
7293 if self.config.execution_profile == ExecutionProfile::Kv {
7294 return self.execute_kv_client_batch(members);
7295 }
7296 let _commit = match self.lock_commit() {
7297 Ok(commit) => commit,
7298 Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7299 };
7300 if let Err(error) = self
7301 .ensure_ready()
7302 .and_then(|_| self.ensure_writes_active())
7303 {
7304 return members.into_iter().map(|_| Err(error.clone())).collect();
7305 }
7306 members
7307 .iter()
7308 .map(|member| self.execute_profile_member_locked(member))
7309 .collect()
7310 }
7311
7312 #[cfg(feature = "graph")]
7313 #[cfg_attr(
7314 all(not(feature = "sql"), not(feature = "kv")),
7315 allow(irrefutable_let_patterns, unreachable_patterns)
7316 )]
7317 fn execute_graph_client_batch(
7318 &self,
7319 members: Vec<RuntimeBatchMember>,
7320 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7321 let _commit = match self.lock_commit() {
7322 Ok(commit) => commit,
7323 Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7324 };
7325 if let Err(error) = self
7326 .ensure_ready()
7327 .and_then(|_| self.ensure_writes_active())
7328 {
7329 return members.into_iter().map(|_| Err(error.clone())).collect();
7330 }
7331
7332 let mut results = vec![None; members.len()];
7333 let mut pending = Vec::new();
7334 let mut canonical_by_request = HashMap::new();
7335 let mut aliases = vec![None; members.len()];
7336 for (index, member) in members.iter().enumerate() {
7337 let QueuedOperation::Graph(command) = &member.operation else {
7338 results[index] = Some(Err(NodeError::ExecutionProfileMismatch {
7339 expected: ExecutionProfile::Graph,
7340 actual: ExecutionProfile::Sqlite,
7341 }));
7342 continue;
7343 };
7344 match self.check_graph_request(command.request_id(), &member.payload) {
7345 Ok(Some(record)) => {
7346 results[index] = Some(Ok(ClientWriteResponse::Graph(
7347 GraphMutationOutcome::from_record(record),
7348 )));
7349 }
7350 Ok(None) => match classify_pending_request(
7351 &mut canonical_by_request,
7352 &members,
7353 index,
7354 command.request_id(),
7355 ) {
7356 Ok(None) => pending.push(index),
7357 Ok(Some(canonical)) => aliases[index] = Some(canonical),
7358 Err(error) => results[index] = Some(Err(error)),
7359 },
7360 Err(error) => results[index] = Some(Err(error)),
7361 }
7362 }
7363
7364 while !pending.is_empty() {
7365 if pending.len() == 1 {
7366 let index = pending[0];
7367 results[index] = Some(self.execute_profile_member_locked(&members[index]));
7368 break;
7369 }
7370 let commands = pending
7371 .iter()
7372 .map(|index| match &members[*index].operation {
7373 QueuedOperation::Graph(command) => command.clone(),
7374 _ => unreachable!("graph pending members were validated above"),
7375 })
7376 .collect::<Vec<_>>();
7377 let full_payload = match encode_replicated_graph_batch(&commands) {
7378 Ok(payload) => payload,
7379 Err(error) => {
7380 let error = NodeError::InvalidRequest(error.to_string());
7381 for index in pending.drain(..) {
7382 results[index] = Some(Err(error.clone()));
7383 }
7384 break;
7385 }
7386 };
7387 let (proposal_count, proposal_payload) = if full_payload.len() <= MAX_COMMAND_BYTES {
7388 (commands.len(), full_payload)
7389 } else {
7390 let mut prefix = None;
7391 for count in (2..commands.len()).rev() {
7392 let payload = encode_replicated_graph_batch(&commands[..count])
7393 .expect("the validated graph batch prefix remains valid");
7394 if payload.len() <= MAX_COMMAND_BYTES {
7395 prefix = Some((count, payload));
7396 break;
7397 }
7398 }
7399 let Some(prefix) = prefix else {
7400 let index = pending.remove(0);
7401 results[index] = Some(self.execute_profile_member_locked(&members[index]));
7402 continue;
7403 };
7404 prefix
7405 };
7406 let proposed_indices = pending[..proposal_count].to_vec();
7407 let (last_index, last_hash) = match self.ensure_materialized_tip() {
7408 Ok(tip) => tip,
7409 Err(error) => {
7410 for index in pending.drain(..) {
7411 results[index] = Some(Err(error.clone()));
7412 }
7413 break;
7414 }
7415 };
7416 let slot = match last_index.checked_add(1) {
7417 Some(slot) => slot,
7418 None => {
7419 let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7420 for index in pending.drain(..) {
7421 results[index] = Some(Err(error.clone()));
7422 }
7423 break;
7424 }
7425 };
7426 let entry = match self.consensus.propose_at_cancellable(
7427 slot,
7428 last_hash,
7429 Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7430 &self.operation_cancelled,
7431 ) {
7432 Ok(entry) => entry,
7433 Err(error) => {
7434 let error = self.map_consensus_error(error);
7435 for index in pending.drain(..) {
7436 results[index] = Some(Err(error.clone()));
7437 }
7438 break;
7439 }
7440 };
7441 if let Err(error) = self.persist_entry(&entry, slot, last_hash) {
7442 for index in pending.drain(..) {
7443 results[index] = Some(Err(error.clone()));
7444 }
7445 break;
7446 }
7447
7448 let mut remaining = Vec::new();
7449 for index in pending.drain(..) {
7450 let member = &members[index];
7451 let QueuedOperation::Graph(command) = &member.operation else {
7452 unreachable!("graph pending members were validated above");
7453 };
7454 match self.check_graph_request(command.request_id(), &member.payload) {
7455 Ok(Some(record)) => {
7456 results[index] = Some(Ok(ClientWriteResponse::Graph(
7457 GraphMutationOutcome::from_record(record),
7458 )));
7459 }
7460 Ok(None) => remaining.push(index),
7461 Err(error) => results[index] = Some(Err(error)),
7462 }
7463 }
7464 if entry.entry_type == EntryType::Command
7465 && entry.payload == proposal_payload
7466 && remaining
7467 .iter()
7468 .any(|index| proposed_indices.contains(index))
7469 {
7470 let error = self.latch(NodeError::Invariant(
7471 "committed graph batch did not record every request".into(),
7472 ));
7473 for index in remaining.drain(..) {
7474 results[index] = Some(Err(error.clone()));
7475 }
7476 }
7477 pending = remaining;
7478 }
7479
7480 for (index, canonical) in aliases.into_iter().enumerate() {
7481 if let Some(canonical) = canonical {
7482 results[index] = results[canonical].clone();
7483 }
7484 }
7485
7486 results
7487 .into_iter()
7488 .map(|result| {
7489 result.unwrap_or_else(|| {
7490 Err(self.latch(NodeError::Invariant(
7491 "graph writer batch omitted a request result".into(),
7492 )))
7493 })
7494 })
7495 .collect()
7496 }
7497
7498 #[cfg(feature = "kv")]
7499 #[cfg_attr(
7500 all(not(feature = "sql"), not(feature = "graph")),
7501 allow(irrefutable_let_patterns, unreachable_patterns)
7502 )]
7503 fn execute_kv_client_batch(
7504 &self,
7505 members: Vec<RuntimeBatchMember>,
7506 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7507 let _commit = match self.lock_commit() {
7508 Ok(commit) => commit,
7509 Err(error) => return members.into_iter().map(|_| Err(error.clone())).collect(),
7510 };
7511 if let Err(error) = self
7512 .ensure_ready()
7513 .and_then(|_| self.ensure_writes_active())
7514 {
7515 return members.into_iter().map(|_| Err(error.clone())).collect();
7516 }
7517 self.execute_kv_client_batch_locked(&members)
7518 }
7519
7520 #[cfg(feature = "kv")]
7521 #[cfg_attr(
7522 all(not(feature = "sql"), not(feature = "graph")),
7523 allow(irrefutable_let_patterns, unreachable_patterns)
7524 )]
7525 fn execute_kv_client_batch_locked(
7526 &self,
7527 members: &[RuntimeBatchMember],
7528 ) -> Vec<Result<ClientWriteResponse, NodeError>> {
7529 let mut results = vec![None; members.len()];
7530 let mut pending = Vec::new();
7531 let mut canonical_by_request = HashMap::new();
7532 let mut aliases = vec![None; members.len()];
7533 let mut lookup_indices = Vec::with_capacity(members.len());
7534 for (index, member) in members.iter().enumerate() {
7535 let QueuedOperation::Kv(command) = &member.operation else {
7536 results[index] = Some(Err(NodeError::ExecutionProfileMismatch {
7537 expected: ExecutionProfile::Kv,
7538 actual: ExecutionProfile::Sqlite,
7539 }));
7540 continue;
7541 };
7542 match classify_pending_request(
7543 &mut canonical_by_request,
7544 members,
7545 index,
7546 command.request_id(),
7547 ) {
7548 Ok(None) => lookup_indices.push(index),
7549 Ok(Some(canonical)) => aliases[index] = Some(canonical),
7550 Err(error) => results[index] = Some(Err(error)),
7551 }
7552 }
7553 let preflight = match self.check_kv_members_bulk(members, &lookup_indices) {
7554 Ok(preflight) => preflight,
7555 Err(error) => return members.iter().map(|_| Err(error.clone())).collect(),
7556 };
7557 for (index, lookup) in preflight {
7558 match lookup {
7559 Ok(Some(record)) => {
7560 results[index] = Some(Ok(ClientWriteResponse::Kv(
7561 KvMutationOutcome::from_record(record),
7562 )));
7563 }
7564 Ok(None) => pending.push(index),
7565 Err(error) => results[index] = Some(Err(error)),
7566 }
7567 }
7568
7569 while !pending.is_empty() {
7570 if pending.len() == 1 {
7571 let index = pending[0];
7572 results[index] = Some(self.execute_profile_member_locked(&members[index]));
7573 break;
7574 }
7575 let commands = pending
7576 .iter()
7577 .map(|index| match &members[*index].operation {
7578 QueuedOperation::Kv(command) => command.clone(),
7579 _ => unreachable!("KV pending members were validated above"),
7580 })
7581 .collect::<Vec<_>>();
7582 let full_payload = match encode_replicated_kv_batch(&commands) {
7583 Ok(payload) => payload,
7584 Err(error) => {
7585 let error = NodeError::InvalidRequest(error.to_string());
7586 for index in pending.drain(..) {
7587 results[index] = Some(Err(error.clone()));
7588 }
7589 break;
7590 }
7591 };
7592 let (proposal_count, proposal_payload) = if full_payload.len() <= MAX_COMMAND_BYTES {
7593 (commands.len(), full_payload)
7594 } else {
7595 let Some(prefix) = largest_fitting_kv_batch_prefix(&commands) else {
7596 let index = pending.remove(0);
7597 results[index] = Some(self.execute_profile_member_locked(&members[index]));
7598 continue;
7599 };
7600 prefix
7601 };
7602 let proposed_indices = pending[..proposal_count].to_vec();
7603 let (last_index, last_hash) = match self.ensure_materialized_tip() {
7604 Ok(tip) => tip,
7605 Err(error) => {
7606 for index in pending.drain(..) {
7607 results[index] = Some(Err(error.clone()));
7608 }
7609 break;
7610 }
7611 };
7612 let slot = match last_index.checked_add(1) {
7613 Some(slot) => slot,
7614 None => {
7615 let error = self.latch(NodeError::Invariant("qlog index is exhausted".into()));
7616 for index in pending.drain(..) {
7617 results[index] = Some(Err(error.clone()));
7618 }
7619 break;
7620 }
7621 };
7622 let entry = match self.consensus.propose_at_cancellable(
7623 slot,
7624 last_hash,
7625 Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7626 &self.operation_cancelled,
7627 ) {
7628 Ok(entry) => entry,
7629 Err(error) => {
7630 let error = self.map_consensus_error(error);
7631 for index in pending.drain(..) {
7632 results[index] = Some(Err(error.clone()));
7633 }
7634 break;
7635 }
7636 };
7637 if let Err(error) = self.persist_entry(&entry, slot, last_hash) {
7638 for index in pending.drain(..) {
7639 results[index] = Some(Err(error.clone()));
7640 }
7641 break;
7642 }
7643
7644 let current_pending = std::mem::take(&mut pending);
7645 let post_commit = match self.check_kv_members_bulk(members, ¤t_pending) {
7646 Ok(post_commit) => post_commit,
7647 Err(error) => {
7648 for index in current_pending {
7649 results[index] = Some(Err(error.clone()));
7650 }
7651 break;
7652 }
7653 };
7654 let mut remaining = Vec::new();
7655 for (index, lookup) in post_commit {
7656 match lookup {
7657 Ok(Some(record)) => {
7658 results[index] = Some(Ok(ClientWriteResponse::Kv(
7659 KvMutationOutcome::from_record(record),
7660 )));
7661 }
7662 Ok(None) => remaining.push(index),
7663 Err(error) => results[index] = Some(Err(error)),
7664 }
7665 }
7666 if entry.entry_type == EntryType::Command
7667 && entry.payload == proposal_payload
7668 && remaining
7669 .iter()
7670 .any(|index| proposed_indices.contains(index))
7671 {
7672 let error = self.latch(NodeError::Invariant(
7673 "committed KV batch did not record every request".into(),
7674 ));
7675 for index in remaining.drain(..) {
7676 results[index] = Some(Err(error.clone()));
7677 }
7678 }
7679 pending = remaining;
7680 }
7681
7682 for (index, canonical) in aliases.into_iter().enumerate() {
7683 if let Some(canonical) = canonical {
7684 results[index] = results[canonical].clone();
7685 }
7686 }
7687
7688 results
7689 .into_iter()
7690 .map(|result| {
7691 result.unwrap_or_else(|| {
7692 Err(self.latch(NodeError::Invariant(
7693 "KV writer batch omitted a request result".into(),
7694 )))
7695 })
7696 })
7697 .collect()
7698 }
7699
7700 #[cfg(feature = "kv")]
7701 #[allow(clippy::infallible_destructuring_match)]
7702 fn check_kv_members_bulk(
7703 &self,
7704 members: &[RuntimeBatchMember],
7705 indices: &[usize],
7706 ) -> Result<Vec<KvMemberCheck>, NodeError> {
7707 if indices.is_empty() {
7708 return Ok(Vec::new());
7709 }
7710 let materializer = self.lock_materializer()?;
7711 let kv = match &*materializer {
7712 Materializer::Kv(kv) => kv,
7713 #[cfg(feature = "sql")]
7714 Materializer::Sql(_) => {
7715 return Err(NodeError::ExecutionProfileMismatch {
7716 expected: ExecutionProfile::Kv,
7717 actual: ExecutionProfile::Sqlite,
7718 });
7719 }
7720 #[cfg(feature = "graph")]
7721 Materializer::Graph(_) => {
7722 return Err(NodeError::ExecutionProfileMismatch {
7723 expected: ExecutionProfile::Kv,
7724 actual: ExecutionProfile::Graph,
7725 });
7726 }
7727 };
7728 let requests = indices
7729 .iter()
7730 .map(|index| {
7731 let command = match &members[*index].operation {
7732 QueuedOperation::Kv(command) => command,
7733 #[cfg(feature = "sql")]
7734 QueuedOperation::KeyValue { .. } | QueuedOperation::Sql(_) => {
7735 unreachable!("KV receipt lookup contains only KV members")
7736 }
7737 #[cfg(feature = "graph")]
7738 QueuedOperation::Graph(_) => {
7739 unreachable!("KV receipt lookup contains only KV members")
7740 }
7741 };
7742 (command.request_id(), members[*index].payload.as_slice())
7743 })
7744 .collect::<Vec<_>>();
7745 let lookups = kv
7746 .check_requests(&requests)
7747 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
7748 if lookups.len() != indices.len() {
7749 return Err(self.latch(NodeError::Invariant(
7750 "KV bulk receipt lookup returned a misaligned result vector".into(),
7751 )));
7752 }
7753 Ok(indices
7754 .iter()
7755 .copied()
7756 .zip(
7757 lookups.into_iter().map(|lookup| {
7758 lookup.map_err(|error| NodeError::InvalidRequest(error.to_string()))
7759 }),
7760 )
7761 .collect())
7762 }
7763
7764 fn execute_profile_member_locked(
7765 &self,
7766 member: &RuntimeBatchMember,
7767 ) -> Result<ClientWriteResponse, NodeError> {
7768 match &member.operation {
7769 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
7770 QueuedOperation::Unavailable => unreachable!("no execution profiles are compiled in"),
7771 #[cfg(feature = "graph")]
7772 QueuedOperation::Graph(command) => self
7773 .mutate_graph_payload_locked(command, member.payload.clone())
7774 .map(ClientWriteResponse::Graph),
7775 #[cfg(feature = "kv")]
7776 QueuedOperation::Kv(command) => self
7777 .mutate_kv_payload_locked(command, member.payload.clone())
7778 .map(ClientWriteResponse::Kv),
7779 #[cfg(feature = "sql")]
7780 QueuedOperation::KeyValue { .. } | QueuedOperation::Sql(_) => {
7781 Err(NodeError::ExecutionProfileMismatch {
7782 expected: self.config.execution_profile,
7783 actual: ExecutionProfile::Sqlite,
7784 })
7785 }
7786 }
7787 }
7788
7789 #[cfg(feature = "sql")]
7790 fn execute_single_member_locked(
7791 &self,
7792 member: &RuntimeBatchMember,
7793 ) -> Result<ClientWriteResponse, NodeError> {
7794 if let QueuedOperation::Sql(command) = &member.operation {
7795 self.execute_sql_payload_locked(command, member.payload.clone())
7796 .map(|outcome| {
7797 ClientWriteResponse::Sql(sql_execute_response(
7798 outcome.response,
7799 outcome.sql_result,
7800 ))
7801 })
7802 } else if let QueuedOperation::KeyValue { key, value } = &member.operation {
7803 self.execute_put_payload_locked(&member.request_id, key, value, member.payload.clone())
7804 .map(|outcome| ClientWriteResponse::KeyValue(outcome.response))
7805 } else {
7806 Err(NodeError::ExecutionProfileMismatch {
7807 expected: ExecutionProfile::Sqlite,
7808 actual: self.config.execution_profile,
7809 })
7810 }
7811 }
7812
7813 #[cfg(feature = "sql")]
7814 fn execute_sql_payload_locked(
7815 &self,
7816 command: &SqlCommand,
7817 request_payload: Vec<u8>,
7818 ) -> Result<ExecutedPayload, NodeError> {
7819 self.ensure_ready()?;
7820 self.ensure_writes_active()?;
7821 if let Some(outcome) = self.check_request(&command.request_id, &request_payload)? {
7822 return Ok(ExecutedPayload {
7823 response: write_response(outcome),
7824 sql_result: self.replay_sql_result(
7825 &command.request_id,
7826 &request_payload,
7827 outcome,
7828 )?,
7829 });
7830 }
7831
7832 loop {
7833 let (last_index, last_hash) = self.ensure_materialized_tip()?;
7834 let proposal_payload =
7835 self.prepare_sql_proposal(command, &request_payload, last_index, last_hash)?;
7836 let slot = last_index.checked_add(1).ok_or_else(|| {
7837 self.latch(NodeError::Invariant("qlog index is exhausted".into()))
7838 })?;
7839 let entry = self
7840 .consensus
7841 .propose_at_cancellable(
7842 slot,
7843 last_hash,
7844 Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7845 &self.operation_cancelled,
7846 )
7847 .map_err(|error| self.map_consensus_error(error))?;
7848 let sql_result = self.persist_entry(&entry, slot, last_hash)?;
7849 if let Some(outcome) = self.check_request(&command.request_id, &request_payload)? {
7850 return Ok(ExecutedPayload {
7851 response: write_response(outcome),
7852 sql_result: sql_result.or(self.replay_sql_result(
7853 &command.request_id,
7854 &request_payload,
7855 outcome,
7856 )?),
7857 });
7858 }
7859 if entry.entry_type == EntryType::Command && entry.payload == proposal_payload {
7860 return Err(self.latch(NodeError::Invariant(
7861 "committed SQL request was not recorded by SQLite".into(),
7862 )));
7863 }
7864 }
7865 }
7866
7867 #[cfg(feature = "sql")]
7868 fn prepare_sql_proposal(
7869 &self,
7870 command: &SqlCommand,
7871 request_payload: &[u8],
7872 base_index: LogIndex,
7873 base_hash: LogHash,
7874 ) -> Result<Vec<u8>, NodeError> {
7875 let sqlite = self.lock_sqlite()?;
7876 let preparation = sqlite.prepare_sql_batch_effect(
7877 &[SqlBatchMember {
7878 command,
7879 request_payload,
7880 }],
7881 base_index,
7882 base_hash,
7883 );
7884 let preparation = match preparation {
7885 Ok(preparation) => preparation,
7886 Err(rhiza_sql::Error::ResourceExhausted(message)) => {
7887 return Err(NodeError::ResourceExhausted(message));
7888 }
7889 Err(error) => return Err(self.map_sqlite_error(error)),
7890 };
7891 let result = preparation
7892 .results
7893 .into_iter()
7894 .next()
7895 .expect("one-member SQL preparation returns one result");
7896 if let Err(error) = result {
7897 if let rhiza_sql::Error::ResourceExhausted(message) = error {
7898 return Err(NodeError::ResourceExhausted(message));
7899 }
7900 if let rhiza_sql::Error::RequestConflict(conflict) = error {
7901 return Err(NodeError::RequestConflict(conflict));
7902 }
7903 let message = error.to_string();
7904 let statement_index = first_invalid_sql_statement(command, |prefix| {
7905 let Ok(prefix_payload) = encode_sql_command(prefix) else {
7906 return true;
7907 };
7908 let prefix_member = [SqlBatchMember {
7909 command: prefix,
7910 request_payload: &prefix_payload,
7911 }];
7912 match sqlite.prepare_sql_batch_effect(&prefix_member, base_index, base_hash) {
7913 Ok(preparation) => preparation
7914 .results
7915 .into_iter()
7916 .next()
7917 .is_none_or(|result| result.is_err()),
7918 Err(_) => true,
7919 }
7920 });
7921 return match statement_index {
7922 Some(statement_index) => Err(NodeError::InvalidSqlStatement {
7923 statement_index,
7924 message,
7925 }),
7926 None => Err(NodeError::InvalidRequest(message)),
7927 };
7928 }
7929 let payload = preparation.effect.ok_or_else(|| {
7930 self.latch(NodeError::Invariant(
7931 "successful SQL preparation omitted its QWAL v3 effect".into(),
7932 ))
7933 })?;
7934 if !payload.starts_with(QWAL_V3_MAGIC) {
7935 return Err(self.latch(NodeError::Invariant(
7936 "SQLite materializer prepared a non-QWAL v3 SQL proposal".into(),
7937 )));
7938 }
7939 if payload.len() > MAX_COMMAND_BYTES {
7940 return Err(NodeError::ResourceExhausted(format!(
7941 "SQL effect exceeds {MAX_COMMAND_BYTES} bytes"
7942 )));
7943 }
7944 Ok(payload)
7945 }
7946
7947 #[cfg(feature = "sql")]
7948 fn execute_put_payload_locked(
7949 &self,
7950 request_id: &str,
7951 key: &str,
7952 value: &str,
7953 payload: Vec<u8>,
7954 ) -> Result<ExecutedPayload, NodeError> {
7955 self.ensure_ready()?;
7956 self.ensure_writes_active()?;
7957
7958 if let Some(outcome) = self.check_request(request_id, &payload)? {
7959 return Ok(ExecutedPayload {
7960 response: write_response(outcome),
7961 sql_result: None,
7962 });
7963 }
7964
7965 loop {
7966 let (last_index, last_hash) = self.ensure_materialized_tip()?;
7967 let proposal_payload = self
7968 .lock_sqlite()?
7969 .prepare_put_effect(request_id, key, value, &payload, last_index, last_hash)
7970 .map_err(|error| self.map_sqlite_error(error))?;
7971 if !proposal_payload.starts_with(QWAL_V3_MAGIC) {
7972 return Err(self.latch(NodeError::Invariant(
7973 "SQLite materializer prepared a non-QWAL v3 legacy put proposal".into(),
7974 )));
7975 }
7976 if proposal_payload.len() > MAX_COMMAND_BYTES {
7977 return Err(NodeError::ResourceExhausted(format!(
7978 "SQLite QWAL effect exceeds {MAX_COMMAND_BYTES} bytes"
7979 )));
7980 }
7981 let slot = last_index.checked_add(1).ok_or_else(|| {
7982 self.latch(NodeError::Invariant("qlog index is exhausted".into()))
7983 })?;
7984 let entry = self
7985 .consensus
7986 .propose_at_cancellable(
7987 slot,
7988 last_hash,
7989 Command::new(CommandKind::Deterministic, proposal_payload.clone()),
7990 &self.operation_cancelled,
7991 )
7992 .map_err(|error| self.map_consensus_error(error))?;
7993 self.persist_entry(&entry, slot, last_hash)?;
7994
7995 if let Some(outcome) = self.check_request(request_id, &payload)? {
7996 return Ok(ExecutedPayload {
7997 response: write_response(outcome),
7998 sql_result: None,
7999 });
8000 }
8001 if entry.entry_type == EntryType::Command && entry.payload == proposal_payload {
8002 return Err(self.latch(NodeError::Invariant(
8003 "committed legacy put request was not recorded by SQLite QWAL".into(),
8004 )));
8005 }
8006 }
8007 }
8008
8009 #[cfg(feature = "sql")]
8010 fn replay_sql_result(
8011 &self,
8012 request_id: &str,
8013 payload: &[u8],
8014 outcome: RequestOutcome,
8015 ) -> Result<Option<SqlCommandResult>, NodeError> {
8016 let sqlite = self.lock_sqlite()?;
8017 let stored = sqlite
8018 .check_sql_request(request_id, payload)
8019 .map_err(|error| self.map_sqlite_error(error))?
8020 .ok_or_else(|| {
8021 self.latch(NodeError::Invariant(
8022 "committed SQL request result is missing".into(),
8023 ))
8024 })?;
8025 if stored.0 != outcome {
8026 return Err(self.latch(NodeError::Invariant(
8027 "stored SQL request outcome changed".into(),
8028 )));
8029 }
8030 Ok(stored.1)
8031 }
8032
8033 #[cfg(feature = "sql")]
8034 fn map_sql_batch_member_error(&self, error: rhiza_sql::Error) -> NodeError {
8035 match error {
8036 rhiza_sql::Error::RequestConflict(conflict) => NodeError::RequestConflict(conflict),
8037 rhiza_sql::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
8038 rhiza_sql::Error::InvalidCommand(message) | rhiza_sql::Error::Sqlite(message) => {
8039 NodeError::InvalidSqlStatement {
8040 statement_index: 0,
8041 message,
8042 }
8043 }
8044 other => self.map_sqlite_error(other),
8045 }
8046 }
8047
8048 #[cfg(feature = "sql")]
8049 pub fn read(&self, key: &str, consistency: ReadConsistency) -> Result<ReadResponse, NodeError> {
8050 validate_key(key)?;
8051 match consistency {
8052 ReadConsistency::Local => self.read_local(key, None),
8053 ReadConsistency::AppliedIndex(required) => self.read_local(key, Some(required)),
8054 ReadConsistency::ReadBarrier => {
8055 let anchor = self.establish_read_barrier()?;
8056 let _commit = self.lock_commit()?;
8057 self.ensure_ready()?;
8058 self.ensure_writes_active()?;
8059 self.validate_read_barrier_descendant_locked(anchor)?;
8060 self.read_local(key, Some(anchor.index()))
8061 }
8062 }
8063 }
8064
8065 #[cfg(feature = "sql")]
8066 pub fn query_sql(
8067 &self,
8068 statement: &SqlStatement,
8069 consistency: ReadConsistency,
8070 max_rows: u32,
8071 ) -> Result<SqlQueryResponse, NodeError> {
8072 if max_rows == 0 || max_rows > MAX_SQL_MAX_ROWS {
8073 return Err(NodeError::InvalidRequest(format!(
8074 "max_rows must be between 1 and {MAX_SQL_MAX_ROWS}"
8075 )));
8076 }
8077 match consistency {
8078 ReadConsistency::Local => self.query_sql_local(statement, None, max_rows),
8079 ReadConsistency::AppliedIndex(required) => {
8080 self.query_sql_local(statement, Some(required), max_rows)
8081 }
8082 ReadConsistency::ReadBarrier => {
8083 let anchor = self.establish_read_barrier()?;
8084 let _commit = self.lock_commit()?;
8085 self.ensure_ready()?;
8086 self.ensure_writes_active()?;
8087 self.validate_read_barrier_descendant_locked(anchor)?;
8088 self.query_sql_local(statement, Some(anchor.index()), max_rows)
8089 }
8090 }
8091 }
8092
8093 pub fn applied_index(&self) -> Result<LogIndex, NodeError> {
8094 self.ensure_ready()?;
8095 self.lock_materializer()?
8096 .applied_index()
8097 .map_err(|error| self.latch(NodeError::Storage(error)))
8098 }
8099
8100 pub fn applied_hash(&self) -> Result<LogHash, NodeError> {
8101 self.ensure_ready()?;
8102 self.lock_materializer()?
8103 .applied_hash()
8104 .map_err(|error| self.latch(NodeError::Storage(error)))
8105 }
8106
8107 pub fn cancel_operations(&self) {
8108 self.operation_cancelled.store(true, Ordering::Release);
8109 #[cfg(feature = "sql")]
8110 self.sql_group_commit.fail_pending(NodeError::Unavailable(
8111 "SQL group commit cancelled during shutdown".into(),
8112 ));
8113 #[cfg(feature = "kv")]
8114 self.kv_group_commit.fail_pending(NodeError::Unavailable(
8115 "KV group commit cancelled during shutdown".into(),
8116 ));
8117 self.read_barriers.cancel_waiters();
8118 self.operation_cancelled_notify.notify_waiters();
8119 }
8120
8121 pub fn materialize_next_decision(&self) -> Result<bool, NodeError> {
8122 let _commit = self.lock_commit()?;
8123 self.ensure_ready()?;
8124 let (last_index, last_hash) = self.ensure_materialized_tip()?;
8125 let slot = last_index
8126 .checked_add(1)
8127 .ok_or_else(|| self.latch(NodeError::Invariant("qlog index is exhausted".into())))?;
8128 match self
8129 .consensus
8130 .inspect_decision_at(slot, last_hash)
8131 .map_err(|error| self.map_consensus_error(error))?
8132 {
8133 DecisionInspection::Committed(entry) => {
8134 self.persist_entry(&entry, slot, last_hash)?;
8135 Ok(true)
8136 }
8137 DecisionInspection::Empty | DecisionInspection::Pending => Ok(false),
8138 DecisionInspection::Unavailable => Err(NodeError::Unavailable(
8139 "decision proof inspection did not reach quorum".into(),
8140 )),
8141 }
8142 }
8143
8144 pub async fn run_background_materializer<F>(
8145 self: Arc<Self>,
8146 poll_interval: Duration,
8147 shutdown: F,
8148 ) -> Result<(), NodeError>
8149 where
8150 F: std::future::Future<Output = ()> + Send,
8151 {
8152 let poll_interval = poll_interval.max(Duration::from_millis(10));
8153 tokio::pin!(shutdown);
8154 loop {
8155 tokio::select! {
8156 () = &mut shutdown => return Ok(()),
8157 () = tokio::time::sleep(poll_interval) => {
8158 loop {
8159 let runtime = Arc::clone(&self);
8160 let mut operation = tokio::task::spawn_blocking(move || runtime.materialize_next_decision());
8161 let (result, shutting_down) = tokio::select! {
8162 () = &mut shutdown => {
8163 self.cancel_operations();
8164 (operation.await, true)
8165 }
8166 result = &mut operation => (result, false),
8167 };
8168 if shutting_down {
8169 return match result {
8170 Ok(Ok(_) | Err(NodeError::Unavailable(_) | NodeError::Contention(_))) => Ok(()),
8171 Ok(Err(error)) => Err(error),
8172 Err(error) => Err(NodeError::Fatal(format!("materializer task failed: {error}"))),
8173 };
8174 }
8175 match result {
8176 Ok(Ok(true)) => continue,
8177 Ok(Ok(false) | Err(NodeError::Unavailable(_) | NodeError::Contention(_))) => break,
8178 Ok(Err(error)) => return Err(error),
8179 Err(error) => return Err(NodeError::Fatal(format!("materializer task failed: {error}"))),
8180 }
8181 }
8182 }
8183 }
8184 }
8185 }
8186
8187 pub const fn config(&self) -> &NodeConfig {
8188 &self.config
8189 }
8190
8191 pub const fn consensus(&self) -> &Arc<ThreeNodeConsensus> {
8192 &self.consensus
8193 }
8194
8195 pub const fn log_store(&self) -> &FileLogStore {
8196 &self.log_store
8197 }
8198
8199 pub fn configuration_state(&self) -> Result<ConfigurationState, NodeError> {
8200 self.log_store
8201 .configuration_state()
8202 .map_err(|error| NodeError::Storage(error.to_string()))
8203 }
8204
8205 pub fn status(&self) -> Result<NodeStatus, NodeError> {
8206 let configuration_state = self.configuration_state()?;
8207 let (configuration_status, active_config_id) = if configuration_state.is_active() {
8208 (
8209 RuntimeConfigurationStatus::Active,
8210 configuration_state.config_id(),
8211 )
8212 } else if configuration_state.config_id() == self.consensus.config_id() {
8213 (
8214 RuntimeConfigurationStatus::Stopped,
8215 configuration_state.config_id(),
8216 )
8217 } else {
8218 (
8219 RuntimeConfigurationStatus::AwaitingActivation,
8220 configuration_state
8221 .config_id()
8222 .checked_add(1)
8223 .ok_or_else(|| {
8224 NodeError::Invariant("successor configuration id is exhausted".into())
8225 })?,
8226 )
8227 };
8228 Ok(NodeStatus {
8229 ready: self.is_ready(),
8230 stop_anchor: configuration_state.stop().copied(),
8231 active_config_id,
8232 active_membership_digest: self.config.membership.digest(),
8233 configuration_status,
8234 configuration_state,
8235 })
8236 }
8237
8238 pub fn stop_current_configuration(&self) -> Result<StopInformation, NodeError> {
8239 let _commit = self.lock_commit()?;
8240 self.stop_current_configuration_locked(None)
8241 }
8242
8243 pub fn stop_current_configuration_for_successor(
8244 &self,
8245 successor: &Membership,
8246 ) -> Result<StopInformation, NodeError> {
8247 let _commit = self.lock_commit()?;
8248 self.stop_current_configuration_locked(Some(successor))
8249 }
8250
8251 pub fn stop_current_configuration_if(
8252 &self,
8253 expected_config_id: u64,
8254 ) -> Result<StopInformation, NodeError> {
8255 let _commit = self.lock_commit()?;
8256 let state = self.configuration_state()?;
8257 if !state.is_active() || state.config_id() != expected_config_id {
8258 return Err(NodeError::PreconditionFailed(
8259 "active configuration does not match expected_config_id".into(),
8260 ));
8261 }
8262 self.stop_current_configuration_locked(None)
8263 }
8264
8265 fn stop_current_configuration_locked(
8266 &self,
8267 successor: Option<&Membership>,
8268 ) -> Result<StopInformation, NodeError> {
8269 self.ensure_ready()?;
8270 self.ensure_writes_active()?;
8271 let state = self.configuration_state()?;
8272 let stop_command = match successor {
8273 Some(successor) => ConfigChange::bound_stop(
8274 self.config.cluster_id.clone(),
8275 state.config_id(),
8276 state.digest(),
8277 state.config_id().checked_add(1).ok_or_else(|| {
8278 NodeError::Invariant("successor config id is exhausted".into())
8279 })?,
8280 successor.members().to_vec(),
8281 )
8282 .map_err(|error| NodeError::Invariant(error.to_string()))?
8283 .to_stored_command(),
8284 None => ConfigChange::stop(state.config_id(), state.digest()).to_stored_command(),
8285 };
8286 loop {
8287 let (last_index, last_hash) = self.ensure_materialized_tip()?;
8288 let slot = last_index
8289 .checked_add(1)
8290 .ok_or_else(|| NodeError::Invariant("qlog index is exhausted".into()))?;
8291 let entry = self
8292 .consensus
8293 .propose_stored_at(slot, last_hash, stop_command.clone())
8294 .map_err(|error| self.map_consensus_error(error))?;
8295 self.persist_entry(&entry, slot, last_hash)?;
8296 let decided = StoredCommand::new(entry.entry_type, entry.payload.clone());
8297 if decided != stop_command {
8298 let current = self.configuration_state()?;
8299 if current.is_active() {
8300 continue;
8301 }
8302 return Err(NodeError::ConfigurationTransition {
8303 state: Box::new(current),
8304 });
8305 }
8306 let proof = self
8307 .consensus
8308 .inspect_decision_proof_at(slot)
8309 .map_err(|error| self.map_consensus_error(error))?
8310 .ok_or_else(|| {
8311 NodeError::Unavailable("durable Stop proof is unavailable".into())
8312 })?;
8313 if proof
8314 .proposal()
8315 .value
8316 .as_ref()
8317 .map(|value| value.entry_hash)
8318 != Some(entry.hash)
8319 {
8320 return Err(self.latch(NodeError::Reconciliation(
8321 "Stop proof differs from committed stop entry".into(),
8322 )));
8323 }
8324 return Ok(StopInformation {
8325 version: 2,
8326 entry,
8327 proof,
8328 });
8329 }
8330 }
8331
8332 pub fn activate_successor(&self) -> Result<LogEntry, NodeError> {
8333 let _commit = self.lock_commit()?;
8334 self.activate_successor_locked(None)
8335 }
8336
8337 pub fn activate_successor_if(&self, expected_config_id: u64) -> Result<LogEntry, NodeError> {
8338 let _commit = self.lock_commit()?;
8339 self.activate_successor_locked(Some(expected_config_id))
8340 }
8341
8342 fn activate_successor_locked(
8343 &self,
8344 expected_config_id: Option<u64>,
8345 ) -> Result<LogEntry, NodeError> {
8346 self.ensure_ready()?;
8347 let state = self.configuration_state()?;
8348 let stop = state
8349 .stop()
8350 .copied()
8351 .ok_or_else(|| NodeError::ConfigurationTransition {
8352 state: Box::new(state.clone()),
8353 })?;
8354 if state.config_id() == self.consensus.config_id() {
8355 return Err(NodeError::ConfigurationTransition {
8356 state: Box::new(state),
8357 });
8358 }
8359 let successor_config_id = state.config_id().checked_add(1).ok_or_else(|| {
8360 NodeError::Invariant("successor configuration id is exhausted".into())
8361 })?;
8362 if expected_config_id.is_some_and(|expected| expected != successor_config_id) {
8363 return Err(NodeError::PreconditionFailed(
8364 "successor configuration does not match expected_config_id".into(),
8365 ));
8366 }
8367 let stop_entry = self.recover_stop_entry(stop)?;
8368 let entry = self
8369 .consensus
8370 .propose_activation_for_stop_entry(&stop_entry)
8371 .map_err(|error| self.map_consensus_error(error))?;
8372 self.persist_entry(&entry, stop.index() + 1, stop.hash())?;
8373 Ok(entry)
8374 }
8375
8376 pub(crate) fn recover_stop_entry(&self, stop: LogAnchor) -> Result<LogEntry, NodeError> {
8377 if let Some(entry) = self
8378 .log_store
8379 .read(stop.index())
8380 .map_err(|error| NodeError::Storage(error.to_string()))?
8381 .filter(|entry| entry.hash == stop.hash())
8382 {
8383 return Ok(entry);
8384 }
8385 if let Some(entry) = self
8386 .config
8387 .predecessor_stop_entry
8388 .as_ref()
8389 .filter(|entry| entry.index == stop.index() && entry.hash == stop.hash())
8390 {
8391 validate_entry_envelope(&self.config, entry, entry.index, entry.prev_hash)?;
8392 return Ok(entry.clone());
8393 }
8394 let proof = self
8395 .consensus
8396 .inspect_decision_proof_at(stop.index())
8397 .map_err(|error| self.map_consensus_error(error))?
8398 .ok_or_else(|| NodeError::Unavailable("durable Stop proof is unavailable".into()))?;
8399 let value = proof
8400 .proposal()
8401 .value
8402 .as_ref()
8403 .filter(|value| value.entry_hash == stop.hash())
8404 .ok_or_else(|| {
8405 self.latch(NodeError::Reconciliation(
8406 "Stop proof differs from compacted anchor".into(),
8407 ))
8408 })?;
8409 match self
8410 .consensus
8411 .inspect_decision_at(stop.index(), value.prev_hash)
8412 .map_err(|error| self.map_consensus_error(error))?
8413 {
8414 DecisionInspection::Committed(entry) if entry.hash == stop.hash() => Ok(entry),
8415 DecisionInspection::Unavailable => Err(NodeError::Unavailable(
8416 "durable Stop command is unavailable".into(),
8417 )),
8418 DecisionInspection::Committed(_)
8419 | DecisionInspection::Empty
8420 | DecisionInspection::Pending => Err(self.latch(NodeError::Reconciliation(
8421 "Stop decision differs from compacted anchor".into(),
8422 ))),
8423 }
8424 }
8425
8426 pub fn log_root(&self) -> Result<LogAnchor, NodeError> {
8427 let _commit = self.lock_commit()?;
8428 self.log_root_unlocked()
8429 }
8430
8431 fn log_root_unlocked(&self) -> Result<LogAnchor, NodeError> {
8432 let state = self
8433 .log_store
8434 .logical_state()
8435 .map_err(|error| NodeError::Storage(error.to_string()))?;
8436 Ok(state.tip.map_or_else(
8437 || {
8438 state
8439 .anchor
8440 .map_or(LogAnchor::new(0, LogHash::ZERO), |anchor| {
8441 *anchor.compacted()
8442 })
8443 },
8444 |entry| LogAnchor::new(entry.index(), entry.hash()),
8445 ))
8446 }
8447
8448 pub fn fetch_log(&self, request: FetchLogRequest) -> Result<FetchLogResponse, FetchLogError> {
8449 fetch_runtime_log(self, request)
8450 }
8451
8452 #[cfg(feature = "sql")]
8453 pub fn create_recovery_snapshot(&self) -> Result<RecoverySnapshot, NodeError> {
8454 let _commit = self.lock_commit()?;
8455 self.ensure_ready()?;
8456 self.ensure_materialized_tip()?;
8457 self.lock_sqlite()?
8458 .create_recovery_snapshot(self.config.recovery_generation)
8459 .map_err(|error| self.map_sqlite_error(error))
8460 }
8461
8462 pub async fn checkpoint_compact(
8463 &self,
8464 coordinator: &CheckpointCoordinator,
8465 ) -> Result<RecoveryAnchor, DurabilityError> {
8466 coordinator.checkpoint_compact(self).await
8467 }
8468
8469 #[cfg_attr(not(any(feature = "sql", feature = "kv")), allow(unused_variables))]
8470 pub(crate) fn compact_embedded_log_before(
8471 &self,
8472 anchor_index: LogIndex,
8473 ) -> Result<(), NodeError> {
8474 let materializer = self.lock_materializer()?;
8475 match &*materializer {
8476 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
8477 Materializer::Unavailable => unreachable!("no execution profiles are compiled in"),
8478 #[cfg(feature = "sql")]
8479 Materializer::Sql(sql) => sql
8480 .compact_embedded_log_before(anchor_index)
8481 .map_err(|error| self.map_sqlite_error(error)),
8482 #[cfg(feature = "kv")]
8483 Materializer::Kv(kv) => kv
8484 .compact_embedded_log_before(anchor_index)
8485 .map_err(|error| NodeError::Storage(error.to_string())),
8486 #[cfg(feature = "graph")]
8487 Materializer::Graph(_) => Ok(()),
8488 }
8489 }
8490
8491 #[cfg(feature = "sql")]
8492 pub fn verify_snapshot_publication(
8493 &self,
8494 snapshot: &RecoverySnapshot,
8495 publication: &SnapshotRecord,
8496 ) -> Result<VerifiedSnapshotPublication, NodeError> {
8497 let anchor = snapshot.anchor();
8498 let manifest = publication.manifest();
8499 let publication_digest = LogHash::from_hex(publication.sha256()).ok_or_else(|| {
8500 NodeError::Reconciliation("published snapshot digest is invalid".into())
8501 })?;
8502 if anchor.cluster_id() != self.config.cluster_id
8503 || anchor.epoch() != self.config.epoch
8504 || anchor.config_id() != self.config.config_id()
8505 || anchor.recovery_generation() != self.config.recovery_generation
8506 || manifest.cluster_id() != anchor.cluster_id()
8507 || manifest.epoch() != anchor.epoch()
8508 || manifest.config_id() != anchor.config_id()
8509 || manifest.configuration_state() != anchor.configuration_state()
8510 || manifest.index() != anchor.compacted().index()
8511 || manifest.applied_hash() != anchor.compacted().hash()
8512 || manifest.snapshot_id() != anchor.snapshot().snapshot_id()
8513 || manifest.executor_fingerprint() != anchor.snapshot().executor_fingerprint()
8514 || publication_digest != anchor.snapshot().digest()
8515 || publication.size_bytes() != anchor.snapshot().size_bytes()
8516 || LogHash::digest(&[snapshot.db_bytes()]) != anchor.snapshot().digest()
8517 || snapshot.db_bytes().len() as u64 != anchor.snapshot().size_bytes()
8518 {
8519 return Err(NodeError::Reconciliation(
8520 "published snapshot does not match the runtime recovery anchor".into(),
8521 ));
8522 }
8523 Ok(VerifiedSnapshotPublication {
8524 anchor: anchor.clone(),
8525 })
8526 }
8527
8528 #[cfg(feature = "sql")]
8529 pub fn compact_log(&self, publication: &VerifiedSnapshotPublication) -> Result<(), NodeError> {
8530 let _commit = self.lock_commit()?;
8531 self.ensure_ready()?;
8532 let applied_index = self.applied_index()?;
8533 let applied_hash = self.applied_hash()?;
8534 let anchor = &publication.anchor;
8535 if anchor.cluster_id() != self.config.cluster_id
8536 || anchor.epoch() != self.config.epoch
8537 || anchor.config_id() != self.config.config_id()
8538 || anchor.recovery_generation() != self.config.recovery_generation
8539 || anchor.compacted().index() != applied_index
8540 || anchor.compacted().hash() != applied_hash
8541 {
8542 return Err(NodeError::Reconciliation(
8543 "verified snapshot anchor does not match the current applied entry".into(),
8544 ));
8545 }
8546 self.log_store
8547 .compact_prefix(anchor)
8548 .map_err(|error| NodeError::Storage(error.to_string()))?;
8549 self.compact_embedded_log_before(anchor.compacted().index())
8550 }
8551
8552 pub fn is_ready(&self) -> bool {
8553 self.ready.load(Ordering::Acquire)
8554 && !self.fatal.load(Ordering::Acquire)
8555 && !self.checkpointing.load(Ordering::Acquire)
8556 }
8557
8558 pub fn is_fatal(&self) -> bool {
8559 self.fatal.load(Ordering::Acquire)
8560 }
8561
8562 pub fn fatal_reason(&self) -> Option<String> {
8563 self.fatal_reason
8564 .lock()
8565 .ok()
8566 .and_then(|reason| reason.clone())
8567 }
8568
8569 #[cfg(feature = "sql")]
8570 fn read_local(
8571 &self,
8572 key: &str,
8573 required_index: Option<LogIndex>,
8574 ) -> Result<ReadResponse, NodeError> {
8575 self.ensure_ready()?;
8576 let sqlite = self.lock_sqlite()?;
8577 let (applied_index, hash) = sqlite
8578 .applied_tip_value()
8579 .map_err(|error| self.map_sqlite_error(error))?;
8580 if required_index.is_some_and(|required| applied_index < required) {
8581 return Err(NodeError::Unavailable(format!(
8582 "local applied index {applied_index} has not reached {}",
8583 required_index.expect("checked above")
8584 )));
8585 }
8586 let value = sqlite
8587 .get_value(key)
8588 .map_err(|error| self.map_sqlite_error(error))?;
8589 Ok(ReadResponse {
8590 value,
8591 applied_index,
8592 hash,
8593 })
8594 }
8595
8596 #[cfg(feature = "graph")]
8597 fn get_graph_document_local(
8598 &self,
8599 id: &str,
8600 required_index: Option<LogIndex>,
8601 ) -> Result<GraphReadResponse, NodeError> {
8602 self.ensure_ready()?;
8603 let graph = self.graph_materializer()?;
8604 let (value, applied_index, hash) = graph
8605 .get_document_with_tip(id)
8606 .map_err(|error| self.map_graph_read_error(error))?;
8607 if required_index.is_some_and(|required| applied_index < required) {
8608 return Err(NodeError::Unavailable(format!(
8609 "local applied index {applied_index} has not reached {}",
8610 required_index.expect("checked above")
8611 )));
8612 }
8613 Ok(GraphReadResponse {
8614 value,
8615 applied_index,
8616 hash,
8617 })
8618 }
8619
8620 #[cfg(feature = "graph")]
8621 fn query_graph_local(
8622 &self,
8623 statement: &str,
8624 parameters: &BTreeMap<String, GraphParameterValue>,
8625 required_index: Option<LogIndex>,
8626 max_rows: u32,
8627 ) -> Result<GraphQueryResult, NodeError> {
8628 self.ensure_ready()?;
8629 let graph = self.graph_materializer()?;
8630 let result = graph
8631 .query_read_only(
8632 statement,
8633 parameters,
8634 usize::try_from(max_rows).expect("u32 fits usize"),
8635 MAX_GRAPH_RESULT_BYTES,
8636 GRAPH_QUERY_TIMEOUT_MS,
8637 )
8638 .map_err(|error| self.map_graph_read_error(error))?;
8639 if required_index.is_some_and(|required| result.applied_index < required) {
8640 return Err(NodeError::Unavailable(format!(
8641 "local applied index {} has not reached {}",
8642 result.applied_index,
8643 required_index.expect("checked above")
8644 )));
8645 }
8646 Ok(result)
8647 }
8648
8649 #[cfg(feature = "kv")]
8650 fn get_kv_local(
8651 &self,
8652 key: &[u8],
8653 required_index: Option<LogIndex>,
8654 ) -> Result<KvReadResponse, NodeError> {
8655 self.ensure_ready()?;
8656 let kv = self.kv_materializer()?;
8657 let result = kv
8658 .get_with_tip(key)
8659 .map_err(|error| self.map_kv_read_error(error))?;
8660 let (value, tip) = result.into_parts();
8661 let applied_index = tip.applied_index();
8662 if required_index.is_some_and(|required| applied_index < required) {
8663 return Err(NodeError::Unavailable(format!(
8664 "local applied index {applied_index} has not reached {}",
8665 required_index.expect("checked above")
8666 )));
8667 }
8668 Ok(KvReadResponse {
8669 value,
8670 applied_index,
8671 hash: tip.applied_hash(),
8672 })
8673 }
8674
8675 #[cfg(feature = "kv")]
8676 fn scan_kv_range_local(
8677 &self,
8678 start: &[u8],
8679 end: Option<&[u8]>,
8680 limit: usize,
8681 cursor: Option<&[u8]>,
8682 required_index: Option<LogIndex>,
8683 ) -> Result<KvScanResult, NodeError> {
8684 self.ensure_ready()?;
8685 let kv = self.kv_materializer()?;
8686 let result = kv
8687 .scan_range(start, end, limit, cursor)
8688 .map_err(|error| self.map_kv_read_error(error))?;
8689 validate_kv_scan_required_index(&result, required_index)?;
8690 Ok(result)
8691 }
8692
8693 #[cfg(feature = "kv")]
8694 fn scan_kv_prefix_local(
8695 &self,
8696 prefix: &[u8],
8697 limit: usize,
8698 cursor: Option<&[u8]>,
8699 required_index: Option<LogIndex>,
8700 ) -> Result<KvScanResult, NodeError> {
8701 self.ensure_ready()?;
8702 let kv = self.kv_materializer()?;
8703 let result = kv
8704 .scan_prefix(prefix, limit, cursor)
8705 .map_err(|error| self.map_kv_read_error(error))?;
8706 validate_kv_scan_required_index(&result, required_index)?;
8707 Ok(result)
8708 }
8709
8710 #[cfg(feature = "sql")]
8711 fn query_sql_local(
8712 &self,
8713 statement: &SqlStatement,
8714 required_index: Option<LogIndex>,
8715 max_rows: u32,
8716 ) -> Result<SqlQueryResponse, NodeError> {
8717 self.ensure_ready()?;
8718 let sqlite = self.lock_sqlite()?;
8719 let (applied_index, hash) = sqlite
8720 .applied_tip_value()
8721 .map_err(|error| self.map_sqlite_error(error))?;
8722 if required_index.is_some_and(|required| applied_index < required) {
8723 return Err(NodeError::Unavailable(format!(
8724 "local applied index {applied_index} has not reached {}",
8725 required_index.expect("checked above")
8726 )));
8727 }
8728 let SqlQueryResult { columns, rows } = sqlite
8729 .query_sql(
8730 statement,
8731 usize::try_from(max_rows).expect("u32 fits usize"),
8732 MAX_SQL_RESULT_BYTES,
8733 )
8734 .map_err(|error| match error {
8735 rhiza_sql::Error::ResourceExhausted(message) => {
8736 NodeError::ResourceExhausted(message)
8737 }
8738 other => NodeError::InvalidSqlStatement {
8739 statement_index: 0,
8740 message: other.to_string(),
8741 },
8742 })?;
8743 Ok(SqlQueryResponse {
8744 columns,
8745 rows,
8746 applied_index,
8747 hash,
8748 })
8749 }
8750
8751 fn establish_read_barrier(&self) -> Result<LogAnchor, NodeError> {
8752 let participant = self.read_barriers.join().map_err(|error| match error {
8753 NodeError::Invariant(_) => self.latch(error),
8754 other => other,
8755 })?;
8756 let Some(mut publication) = participant.publication() else {
8757 return participant.wait(&self.operation_cancelled);
8758 };
8759
8760 let result = (|| {
8761 publication.wait_turn(&self.operation_cancelled)?;
8762 let _commit = self.lock_commit()?;
8766 publication.start(&self.operation_cancelled)?;
8767 self.ensure_ready()?;
8768 self.commit_read_barrier_locked()
8769 })();
8770 publication.publish(result.clone());
8771 result
8772 }
8773
8774 #[cfg(any(feature = "graph", feature = "kv"))]
8775 fn validate_read_barrier_before_snapshot(&self, anchor: LogAnchor) -> Result<(), NodeError> {
8776 {
8777 let _commit = self.lock_commit()?;
8778 self.ensure_ready()?;
8779 self.ensure_writes_active()?;
8780 self.validate_read_barrier_qlog_descendant_locked(anchor)?;
8781 }
8782 Ok(())
8783 }
8784
8785 #[cfg(any(feature = "graph", feature = "kv"))]
8786 fn validate_read_barrier_snapshot(
8787 &self,
8788 anchor: LogAnchor,
8789 observed: LogAnchor,
8790 ) -> Result<(), NodeError> {
8791 if observed.index() < anchor.index() {
8792 return Err(NodeError::Unavailable(format!(
8793 "read snapshot tip {} precedes read barrier {}",
8794 observed.index(),
8795 anchor.index()
8796 )));
8797 }
8798 if observed.index() == anchor.index() && observed.hash() != anchor.hash() {
8799 return Err(self.latch(NodeError::Invariant(
8800 "read snapshot tip hash differs from the read barrier anchor".into(),
8801 )));
8802 }
8803 Ok(())
8804 }
8805
8806 #[cfg(feature = "sql")]
8807 fn validate_read_barrier_descendant_locked(&self, anchor: LogAnchor) -> Result<(), NodeError> {
8808 let (applied_index, applied_hash) = self.ensure_materialized_tip()?;
8809 self.validate_read_barrier_descendant_from_tip(
8810 anchor,
8811 LogAnchor::new(applied_index, applied_hash),
8812 "materialized",
8813 )
8814 }
8815
8816 #[cfg(any(feature = "graph", feature = "kv"))]
8817 fn validate_read_barrier_qlog_descendant_locked(
8818 &self,
8819 anchor: LogAnchor,
8820 ) -> Result<(), NodeError> {
8821 let (qlog_index, qlog_hash) = self.durable_tip()?;
8822 self.validate_read_barrier_descendant_from_tip(
8823 anchor,
8824 LogAnchor::new(qlog_index, qlog_hash),
8825 "qlog",
8826 )
8827 }
8828
8829 fn validate_read_barrier_descendant_from_tip(
8830 &self,
8831 anchor: LogAnchor,
8832 tip: LogAnchor,
8833 tip_kind: &str,
8834 ) -> Result<(), NodeError> {
8835 if tip.index() < anchor.index() {
8836 return Err(self.latch(NodeError::Invariant(format!(
8837 "{tip_kind} tip {} precedes read barrier {}",
8838 tip.index(),
8839 anchor.index()
8840 ))));
8841 }
8842 if tip.index() == anchor.index() {
8843 if tip.hash() != anchor.hash() {
8844 return Err(self.latch(NodeError::Invariant(format!(
8845 "{tip_kind} tip hash differs from the read barrier anchor"
8846 ))));
8847 }
8848 return Ok(());
8849 }
8850 if anchor.index() == 0 {
8851 if anchor.hash() == LogHash::ZERO {
8852 return Ok(());
8853 }
8854 return Err(self.latch(NodeError::Invariant(
8855 "genesis read barrier anchor has a non-zero hash".into(),
8856 )));
8857 }
8858
8859 let logical = self
8860 .log_store
8861 .logical_state()
8862 .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
8863 if let Some(compacted) = logical.anchor.as_ref().map(RecoveryAnchor::compacted) {
8864 if compacted.index() > anchor.index() {
8865 return Ok(());
8866 }
8867 if compacted.index() == anchor.index() {
8868 if compacted.hash() == anchor.hash() {
8869 return Ok(());
8870 }
8871 return Err(self.latch(NodeError::Invariant(
8872 "compacted qlog hash differs from the read barrier anchor".into(),
8873 )));
8874 }
8875 }
8876 let retained = self
8877 .log_store
8878 .read(anchor.index())
8879 .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?
8880 .ok_or_else(|| {
8881 self.latch(NodeError::Invariant(
8882 "read barrier anchor is neither retained nor compacted".into(),
8883 ))
8884 })?;
8885 if retained.hash != anchor.hash() {
8886 return Err(self.latch(NodeError::Invariant(
8887 "retained qlog hash differs from the read barrier anchor".into(),
8888 )));
8889 }
8890 Ok(())
8891 }
8892
8893 fn commit_read_barrier_locked(&self) -> Result<LogAnchor, NodeError> {
8894 self.ensure_writes_active()?;
8895 let context_read_fence = self.consensus.supports_context_read_fence();
8896 loop {
8897 self.ensure_ready()?;
8898 let (last_index, last_hash) = self.ensure_materialized_tip()?;
8899 let slot = last_index.checked_add(1).ok_or_else(|| {
8900 self.latch(NodeError::Invariant("qlog index is exhausted".into()))
8901 })?;
8902 let inspection = if context_read_fence {
8903 match self
8904 .consensus
8905 .inspect_context_read_fence_at(slot, last_hash)
8906 .map_err(|error| self.map_consensus_error(error))?
8907 {
8908 CertifiedDecisionInspection::Committed(certified) => {
8909 DecisionInspection::Committed(certified.entry)
8910 }
8911 CertifiedDecisionInspection::Empty => DecisionInspection::Empty,
8912 CertifiedDecisionInspection::Pending => DecisionInspection::Pending,
8913 CertifiedDecisionInspection::Unavailable => DecisionInspection::Unavailable,
8914 }
8915 } else {
8916 self.consensus
8917 .inspect_decision_at(slot, last_hash)
8918 .map_err(|error| self.map_consensus_error(error))?
8919 };
8920 match inspection {
8921 DecisionInspection::Committed(entry) => {
8922 self.persist_entry(&entry, slot, last_hash)?;
8923 }
8924 DecisionInspection::Empty if context_read_fence => {
8925 self.ensure_writes_active()?;
8929 return Ok(LogAnchor::new(last_index, last_hash));
8930 }
8931 DecisionInspection::Pending => {
8932 let entry = self
8933 .consensus
8934 .propose_at_cancellable(
8935 slot,
8936 last_hash,
8937 Command::new(CommandKind::ReadBarrier, Vec::new()),
8938 &self.operation_cancelled,
8939 )
8940 .map_err(|error| self.map_consensus_error(error))?;
8941 self.persist_entry(&entry, slot, last_hash)?;
8942 }
8946 DecisionInspection::Empty => {
8947 let entry = self
8948 .consensus
8949 .propose_at_cancellable(
8950 slot,
8951 last_hash,
8952 Command::new(CommandKind::ReadBarrier, Vec::new()),
8953 &self.operation_cancelled,
8954 )
8955 .map_err(|error| self.map_consensus_error(error))?;
8956 let is_barrier =
8957 entry.entry_type == EntryType::Noop && entry.payload.is_empty();
8958 self.persist_entry(&entry, slot, last_hash)?;
8959 if is_barrier {
8960 return Ok(LogAnchor::new(entry.index, entry.hash));
8961 }
8962 }
8963 DecisionInspection::Unavailable => {
8964 return Err(NodeError::Unavailable(
8965 "decision inspection did not reach quorum".into(),
8966 ));
8967 }
8968 }
8969 }
8970 }
8971
8972 #[cfg(feature = "sql")]
8973 fn check_request(
8974 &self,
8975 request_id: &str,
8976 payload: &[u8],
8977 ) -> Result<Option<RequestOutcome>, NodeError> {
8978 let sqlite = self.lock_sqlite()?;
8979 sqlite
8980 .check_request(request_id, payload)
8981 .map_err(|error| self.map_sqlite_error(error))
8982 }
8983
8984 #[cfg(feature = "graph")]
8985 #[cfg_attr(
8986 all(not(feature = "sql"), not(feature = "kv")),
8987 allow(irrefutable_let_patterns)
8988 )]
8989 fn check_graph_request(
8990 &self,
8991 request_id: &str,
8992 payload: &[u8],
8993 ) -> Result<Option<GraphRequestRecord>, NodeError> {
8994 let materializer = self.lock_materializer()?;
8995 let Materializer::Graph(graph) = &*materializer else {
8996 return Err(NodeError::ExecutionProfileMismatch {
8997 expected: ExecutionProfile::Graph,
8998 actual: materializer.profile(),
8999 });
9000 };
9001 graph
9002 .check_request(request_id, payload)
9003 .map_err(|error| NodeError::InvalidRequest(error.to_string()))
9004 }
9005
9006 #[cfg(feature = "kv")]
9007 #[cfg_attr(
9008 all(not(feature = "sql"), not(feature = "graph")),
9009 allow(irrefutable_let_patterns)
9010 )]
9011 fn check_kv_request(
9012 &self,
9013 request_id: &str,
9014 payload: &[u8],
9015 ) -> Result<Option<KvRequestRecord>, NodeError> {
9016 let materializer = self.lock_materializer()?;
9017 let Materializer::Kv(kv) = &*materializer else {
9018 return Err(NodeError::ExecutionProfileMismatch {
9019 expected: ExecutionProfile::Kv,
9020 actual: materializer.profile(),
9021 });
9022 };
9023 kv.check_request(request_id, payload)
9024 .map_err(|error| NodeError::InvalidRequest(error.to_string()))
9025 }
9026
9027 fn ensure_materialized_tip(&self) -> Result<(LogIndex, LogHash), NodeError> {
9028 #[cfg(test)]
9029 self.materialized_tip_checks.fetch_add(1, Ordering::Relaxed);
9030 let (last_index, last_hash) = self.durable_tip()?;
9031 let materializer = self.lock_materializer()?;
9032 let applied_tip = materializer
9033 .applied_tip()
9034 .map_err(|error| self.latch(NodeError::Storage(error)))?;
9035 let applied_index = applied_tip.index();
9036 let applied_hash = applied_tip.hash();
9037 if (applied_index, applied_hash) != (last_index, last_hash) {
9038 return Err(self.latch(NodeError::Invariant(format!(
9039 "qlog tip {last_index}/{} differs from {} materializer tip {applied_index}/{}",
9040 last_hash.to_hex(),
9041 materializer.profile(),
9042 applied_hash.to_hex()
9043 ))));
9044 }
9045 Ok((last_index, last_hash))
9046 }
9047
9048 fn durable_tip(&self) -> Result<(LogIndex, LogHash), NodeError> {
9049 static_log_tip(&self.log_store).map_err(|error| self.latch(error))
9050 }
9051
9052 fn persist_entry(
9053 &self,
9054 entry: &LogEntry,
9055 expected_index: LogIndex,
9056 expected_prev_hash: LogHash,
9057 ) -> Result<Option<SqlCommandResult>, NodeError> {
9058 let configuration_state = self.configuration_state()?;
9059 validate_runtime_entry(
9060 &self.config,
9061 &configuration_state,
9062 entry,
9063 expected_index,
9064 expected_prev_hash,
9065 )
9066 .map_err(|error| self.latch(error))?;
9067 if matches!(
9068 self.config.execution_profile,
9069 ExecutionProfile::Sqlite | ExecutionProfile::Kv
9070 ) {
9071 self.log_store
9074 .append_batch_buffered(std::slice::from_ref(entry))
9075 .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9076 } else {
9077 self.log_store
9078 .append(entry)
9079 .map_err(|error| self.latch(NodeError::Storage(error.to_string())))?;
9080 }
9081 self.lock_materializer()?
9082 .apply_entry(entry)
9083 .map_err(|error| self.latch(NodeError::Invariant(error)))
9084 }
9085
9086 #[cfg(feature = "sql")]
9087 fn persist_sql_entry_profiled<P: SqlWritePhaseProfile>(
9088 &self,
9089 entry: &LogEntry,
9090 expected_index: LogIndex,
9091 expected_prev_hash: LogHash,
9092 profile: &mut P,
9093 ) -> Result<Option<SqlCommandResult>, NodeError> {
9094 let configuration_state = self.configuration_state()?;
9095 validate_runtime_entry(
9096 &self.config,
9097 &configuration_state,
9098 entry,
9099 expected_index,
9100 expected_prev_hash,
9101 )
9102 .map_err(|error| self.latch(error))?;
9103
9104 let qlog_mark = profile.mark();
9105 let append_result = self
9106 .log_store
9107 .append_batch_buffered(std::slice::from_ref(entry))
9108 .map_err(|error| self.latch(NodeError::Storage(error.to_string())));
9109 profile.add_local_qlog_mirror_append(qlog_mark);
9110 append_result?;
9111
9112 let materializer_mark = profile.mark();
9113 let apply_result = self
9114 .lock_materializer()?
9115 .apply_entry(entry)
9116 .map_err(|error| self.latch(NodeError::Invariant(error)));
9117 profile.add_sql_materializer_apply(materializer_mark);
9118 apply_result
9119 }
9120
9121 fn require_execution_profile(&self, expected: ExecutionProfile) -> Result<(), NodeError> {
9122 if self.config.execution_profile == expected {
9123 Ok(())
9124 } else {
9125 Err(NodeError::ExecutionProfileMismatch {
9126 expected,
9127 actual: self.config.execution_profile,
9128 })
9129 }
9130 }
9131
9132 fn ensure_ready(&self) -> Result<(), NodeError> {
9133 if self.fatal.load(Ordering::Acquire) {
9134 return Err(NodeError::Fatal(
9135 self.fatal_reason()
9136 .unwrap_or_else(|| "fatal state is latched".into()),
9137 ));
9138 }
9139 if !self.ready.load(Ordering::Acquire) {
9140 return Err(NodeError::Unavailable("runtime is not ready".into()));
9141 }
9142 if self.checkpointing.load(Ordering::Acquire) {
9143 return Err(NodeError::Unavailable(
9144 "runtime checkpoint transition is in progress".into(),
9145 ));
9146 }
9147 Ok(())
9148 }
9149
9150 fn ensure_writes_active(&self) -> Result<(), NodeError> {
9151 let state = self.configuration_state()?;
9152 if state.is_active() {
9153 Ok(())
9154 } else {
9155 Err(NodeError::ConfigurationTransition {
9156 state: Box::new(state),
9157 })
9158 }
9159 }
9160
9161 fn lock_commit(&self) -> Result<MutexGuard<'_, ()>, NodeError> {
9162 self.commit
9163 .lock()
9164 .map_err(|_| self.latch(NodeError::Invariant("commit mutex is poisoned".into())))
9165 }
9166
9167 fn lock_materializer(&self) -> Result<MutexGuard<'_, Materializer>, NodeError> {
9168 self.materializer.lock().map_err(|_| {
9169 self.latch(NodeError::Invariant(
9170 "materializer mutex is poisoned".into(),
9171 ))
9172 })
9173 }
9174
9175 #[cfg(feature = "graph")]
9176 #[cfg_attr(
9177 all(not(feature = "sql"), not(feature = "kv")),
9178 allow(irrefutable_let_patterns)
9179 )]
9180 fn graph_materializer(&self) -> Result<Arc<LadybugStateMachine>, NodeError> {
9181 let materializer = self.lock_materializer()?;
9182 let Materializer::Graph(graph) = &*materializer else {
9183 return Err(NodeError::ExecutionProfileMismatch {
9184 expected: ExecutionProfile::Graph,
9185 actual: materializer.profile(),
9186 });
9187 };
9188 Ok(Arc::clone(graph))
9189 }
9190
9191 #[cfg(feature = "kv")]
9192 #[cfg_attr(
9193 all(not(feature = "sql"), not(feature = "graph")),
9194 allow(irrefutable_let_patterns)
9195 )]
9196 fn kv_materializer(&self) -> Result<Arc<RedbStateMachine>, NodeError> {
9197 let materializer = self.lock_materializer()?;
9198 let Materializer::Kv(kv) = &*materializer else {
9199 return Err(NodeError::ExecutionProfileMismatch {
9200 expected: ExecutionProfile::Kv,
9201 actual: materializer.profile(),
9202 });
9203 };
9204 Ok(Arc::clone(kv))
9205 }
9206
9207 #[cfg(feature = "sql")]
9208 fn lock_sqlite(&self) -> Result<SqlMaterializerGuard<'_>, NodeError> {
9209 let guard = self.lock_materializer()?;
9210 if !matches!(&*guard, Materializer::Sql(_)) {
9211 return Err(NodeError::ExecutionProfileMismatch {
9212 expected: ExecutionProfile::Sqlite,
9213 actual: guard.profile(),
9214 });
9215 }
9216 Ok(SqlMaterializerGuard(guard))
9217 }
9218
9219 #[cfg(feature = "sql")]
9220 fn map_sqlite_error(&self, error: rhiza_sql::Error) -> NodeError {
9221 match error {
9222 rhiza_sql::Error::RequestConflict(conflict) => NodeError::RequestConflict(conflict),
9223 rhiza_sql::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9224 rhiza_sql::Error::InvalidCommand(message)
9225 | rhiza_sql::Error::IdentityMismatch(message)
9226 | rhiza_sql::Error::InvalidEntry(message)
9227 | rhiza_sql::Error::InvalidSnapshot(message) => {
9228 self.latch(NodeError::Invariant(message))
9229 }
9230 other => self.latch(NodeError::Storage(other.to_string())),
9231 }
9232 }
9233
9234 #[cfg(feature = "graph")]
9235 fn map_graph_read_error(&self, error: rhiza_graph::Error) -> NodeError {
9236 match error {
9237 rhiza_graph::Error::InvalidCommand(_) => NodeError::InvalidRequest(error.to_string()),
9238 rhiza_graph::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9239 rhiza_graph::Error::Ladybug(_) | rhiza_graph::Error::Io(_) => {
9240 self.latch(NodeError::Storage(error.to_string()))
9241 }
9242 rhiza_graph::Error::Closed
9243 | rhiza_graph::Error::Codec(_)
9244 | rhiza_graph::Error::InvalidEntry(_)
9245 | rhiza_graph::Error::IdentityMismatch(_)
9246 | rhiza_graph::Error::RequestConflict { .. }
9247 | rhiza_graph::Error::InvalidSnapshot(_) => {
9248 self.latch(NodeError::Invariant(error.to_string()))
9249 }
9250 }
9251 }
9252
9253 #[cfg(feature = "kv")]
9254 fn map_kv_read_error(&self, error: rhiza_kv::Error) -> NodeError {
9255 match error {
9256 rhiza_kv::Error::InvalidCommand(_) | rhiza_kv::Error::InvalidQuery(_) => {
9257 NodeError::InvalidRequest(error.to_string())
9258 }
9259 rhiza_kv::Error::ResourceExhausted(message) => NodeError::ResourceExhausted(message),
9260 rhiza_kv::Error::Database(_) | rhiza_kv::Error::Io(_) => {
9261 self.latch(NodeError::Storage(error.to_string()))
9262 }
9263 rhiza_kv::Error::Codec(_)
9264 | rhiza_kv::Error::InvalidEntry(_)
9265 | rhiza_kv::Error::PartialInitialization
9266 | rhiza_kv::Error::RequestConflict { .. }
9267 | rhiza_kv::Error::InvalidSnapshot(_) => {
9268 self.latch(NodeError::Invariant(error.to_string()))
9269 }
9270 }
9271 }
9272
9273 fn map_consensus_error(&self, error: rhiza_quepaxa::Error) -> NodeError {
9274 match error {
9275 rhiza_quepaxa::Error::NoQuorum
9276 | rhiza_quepaxa::Error::ProposeFailed
9277 | rhiza_quepaxa::Error::CommandUnavailable
9278 | rhiza_quepaxa::Error::Cancelled
9279 | rhiza_quepaxa::Error::Io(_) => NodeError::Unavailable(error.to_string()),
9280 rhiza_quepaxa::Error::ConflictingCertificates
9281 | rhiza_quepaxa::Error::ChainConflict { .. } => {
9282 self.latch(NodeError::Reconciliation(error.to_string()))
9283 }
9284 other => self.latch(NodeError::Invariant(other.to_string())),
9285 }
9286 }
9287
9288 fn latch(&self, error: NodeError) -> NodeError {
9289 self.ready.store(false, Ordering::Release);
9290 if !self.fatal.swap(true, Ordering::AcqRel) {
9291 eprintln!(
9292 "node runtime entered fatal state: {}",
9293 escaped_error_detail(&error)
9294 );
9295 if let Ok(mut reason) = self.fatal_reason.lock() {
9296 *reason = Some(error.to_string());
9297 }
9298 }
9299 error
9300 }
9301}
9302
9303pub fn rehydrate_recorder_after_checkpoint(
9304 runtime: &NodeRuntime,
9305 recorder: &RecorderFileStore,
9306 checkpoint_index: LogIndex,
9307) -> Result<(), NodeError> {
9308 if let Some(anchor) = runtime
9309 .log_store()
9310 .logical_state()
9311 .map_err(|error| NodeError::Storage(error.to_string()))?
9312 .anchor
9313 {
9314 if checkpoint_index < anchor.compacted().index() {
9315 return Err(NodeError::SnapshotRequired(Box::new(anchor)));
9316 }
9317 }
9318 let applied_index = runtime.applied_index()?;
9319 if checkpoint_index > applied_index {
9320 return Err(NodeError::Reconciliation(format!(
9321 "checkpoint tip {checkpoint_index} is ahead of local applied index {applied_index}"
9322 )));
9323 }
9324
9325 for index in checkpoint_index.saturating_add(1)..=applied_index {
9326 let entry = runtime
9327 .log_store()
9328 .read(index)
9329 .map_err(|error| NodeError::Storage(error.to_string()))?
9330 .ok_or_else(|| {
9331 NodeError::Reconciliation(format!(
9332 "qlog entry {index} is missing during recorder rehydration"
9333 ))
9334 })?;
9335 let certified = match runtime
9336 .consensus()
9337 .inspect_certified_decision_at(index, entry.prev_hash)
9338 .map_err(startup_consensus_error)?
9339 {
9340 CertifiedDecisionInspection::Committed(certified) => certified,
9341 CertifiedDecisionInspection::Empty => {
9342 return Err(NodeError::Reconciliation(format!(
9343 "qlog entry {index} has no recorder decision certificate"
9344 )))
9345 }
9346 CertifiedDecisionInspection::Pending => {
9347 return Err(NodeError::Reconciliation(format!(
9348 "qlog entry {index} has only a pending recorder decision"
9349 )))
9350 }
9351 CertifiedDecisionInspection::Unavailable => {
9352 return Err(NodeError::Unavailable(format!(
9353 "recorder decision certificate is unavailable at qlog index {index}"
9354 )))
9355 }
9356 };
9357 if certified.entry != entry {
9358 return Err(NodeError::Reconciliation(format!(
9359 "recorder decision certificate differs from qlog entry {index}"
9360 )));
9361 }
9362 let command = StoredCommand::new(entry.entry_type, entry.payload.clone());
9363 recorder
9364 .store_command(command.hash(), command)
9365 .map_err(|error| {
9366 NodeError::Reconciliation(format!(
9367 "cannot restore recorder command at qlog index {index}: {error}"
9368 ))
9369 })?;
9370 let proof = certified.proof.clone();
9371 recorder
9372 .install_decision_proof_record(proof, runtime.consensus().membership())
9373 .map_err(|error| {
9374 NodeError::Reconciliation(format!(
9375 "cannot install recorder decision at qlog index {index}: {error}"
9376 ))
9377 })?;
9378 }
9379 Ok(())
9380}
9381
9382#[cfg(test)]
9383mod tests {
9384 use axum::http::HeaderValue;
9385
9386 use rhiza_core::{
9387 Command, CommandKind, ConfigurationState, EntryType, ErrorCategory, ErrorClassification,
9388 ExecutionProfile, LogAnchor, LogHash, RecoveryAnchor, SnapshotIdentity, StoredCommand,
9389 };
9390 #[cfg(feature = "graph")]
9391 use rhiza_graph::{GraphCommandV1, GraphValueV1};
9392 #[cfg(feature = "kv")]
9393 use rhiza_kv::KvCommandV1;
9394 use rhiza_log::LogStore as _;
9395 use rhiza_quepaxa::{
9396 AcceptedValue, Membership, Proposal, ProposalPriority, RecordRequest, ThreeNodeConsensus,
9397 };
9398 use std::sync::{
9399 atomic::{AtomicBool, AtomicUsize, Ordering},
9400 mpsc, Arc, Barrier,
9401 };
9402
9403 #[cfg(any(feature = "sql", feature = "graph", feature = "kv"))]
9404 use super::node_error_response;
9405 #[cfg(feature = "graph")]
9406 use super::with_graph_client_permit;
9407 use super::ReadBarrierRounds;
9408 use super::{
9409 client_authenticated, next_sync_flush_retry, run_read_operation, sql_query_http_response,
9410 valid_recorder_record, Duration, HeaderMap, NodeError, ReadConsistency, SqlCommand,
9411 SqlQueryResponse, SqlStatement, SqlValue, SqlWriteProfiler, MAX_COMMAND_BYTES,
9412 MAX_SQL_RESPONSE_BYTES, PROTOCOL_VERSION, QWAL_V3_MAGIC, SYNC_FLUSH_RETRY_INITIAL,
9413 VERSION_HEADER,
9414 };
9415 use super::{ConfigError, NodeConfig, NodeRuntime, NodeService};
9416
9417 #[test]
9418 fn embedded_config_accepts_matching_canonical_profile_ids() {
9419 for (cluster_id, profile) in [
9420 ("rhiza:graph:embedded", ExecutionProfile::Graph),
9421 ("rhiza:kv:embedded", ExecutionProfile::Kv),
9422 ] {
9423 let config = NodeConfig::new_embedded(
9424 cluster_id,
9425 "n1",
9426 std::env::temp_dir().join(profile.as_str()),
9427 1,
9428 1,
9429 ["n1", "n2", "n3"],
9430 )
9431 .unwrap()
9432 .with_execution_profile(profile)
9433 .unwrap();
9434
9435 assert_eq!(config.cluster_id(), cluster_id);
9436 assert_eq!(config.logical_cluster_id(), "embedded");
9437 }
9438 }
9439
9440 #[test]
9441 fn embedded_config_rejects_conflicting_canonical_profile_and_preserves_logical_ids() {
9442 let conflicting = NodeConfig::new_embedded(
9443 "rhiza:graph:embedded",
9444 "n1",
9445 std::env::temp_dir().join("conflicting-profile"),
9446 1,
9447 1,
9448 ["n1", "n2", "n3"],
9449 )
9450 .unwrap()
9451 .with_execution_profile(ExecutionProfile::Sqlite)
9452 .unwrap_err();
9453 assert!(matches!(
9454 conflicting,
9455 ConfigError::ClusterIdProfileMismatch {
9456 expected: ExecutionProfile::Sqlite,
9457 actual: ExecutionProfile::Graph,
9458 }
9459 ));
9460
9461 let logical = NodeConfig::new_embedded(
9462 "embedded",
9463 "n1",
9464 std::env::temp_dir().join("logical-profile"),
9465 1,
9466 1,
9467 ["n1", "n2", "n3"],
9468 )
9469 .unwrap();
9470 assert_eq!(logical.cluster_id(), "rhiza:sql:embedded");
9471 assert_eq!(logical.logical_cluster_id(), "embedded");
9472 }
9473
9474 #[test]
9475 fn node_error_classification_reports_observable_retry_semantics() {
9476 let cases = [
9477 (
9478 NodeError::InvalidRequest("missing key".into()),
9479 "invalid_request",
9480 ErrorCategory::InvalidRequest,
9481 false,
9482 ),
9483 (
9484 NodeError::PreconditionFailed("stale version".into()),
9485 "precondition_failed",
9486 ErrorCategory::Conflict,
9487 false,
9488 ),
9489 (
9490 NodeError::Unavailable("no quorum".into()),
9491 "unavailable",
9492 ErrorCategory::Unavailable,
9493 true,
9494 ),
9495 (
9496 NodeError::ResourceExhausted("result too large".into()),
9497 "resource_exhausted",
9498 ErrorCategory::ResourceExhausted,
9499 true,
9500 ),
9501 (
9502 NodeError::Invariant("invalid log".into()),
9503 "invariant_violation",
9504 ErrorCategory::Internal,
9505 false,
9506 ),
9507 ];
9508
9509 for (error, code, category, retryable) in cases {
9510 let classification = error.classification();
9511
9512 assert_eq!(classification.code(), code);
9513 assert_eq!(classification.category(), category);
9514 assert_eq!(classification.retryable(), retryable);
9515 }
9516 }
9517
9518 #[cfg(feature = "sql")]
9519 #[test]
9520 fn sql_batch_error_classification_preserves_statement_index_category() {
9521 let error = NodeError::InvalidSqlStatement {
9522 statement_index: 3,
9523 message: "syntax error".into(),
9524 };
9525
9526 let classification = error.classification();
9527
9528 assert_eq!(classification.code(), "invalid_request");
9529 assert_eq!(classification.category(), ErrorCategory::InvalidRequest);
9530 assert!(!classification.retryable());
9531 }
9532
9533 #[cfg(feature = "sql")]
9534 #[tokio::test]
9535 async fn node_error_http_response_preserves_v1_contract() {
9536 let snapshot = RecoveryAnchor::new(
9537 "cluster",
9538 1,
9539 ConfigurationState::active(1, LogHash::digest(&[b"node-error-test-config"])),
9540 1,
9541 LogAnchor::new(1, LogHash::ZERO),
9542 SnapshotIdentity::new(
9543 "snapshot",
9544 LogHash::digest(&[b"node-error-test-snapshot"]),
9545 0,
9546 rhiza_sql::sql_executor_fingerprint().unwrap(),
9547 ),
9548 );
9549 let cases = vec![
9550 (
9551 NodeError::InvalidSqlStatement {
9552 statement_index: 3,
9553 message: "syntax error".into(),
9554 },
9555 axum::http::StatusCode::BAD_REQUEST,
9556 "invalid_request",
9557 false,
9558 Some(3),
9559 ),
9560 (
9561 NodeError::PreconditionFailed("stale version".into()),
9562 axum::http::StatusCode::CONFLICT,
9563 "precondition_failed",
9564 false,
9565 None,
9566 ),
9567 (
9568 NodeError::SnapshotRequired(Box::new(snapshot)),
9569 axum::http::StatusCode::SERVICE_UNAVAILABLE,
9570 "snapshot_required",
9571 false,
9572 None,
9573 ),
9574 (
9575 NodeError::Storage("disk failed".into()),
9576 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
9577 "storage_error",
9578 false,
9579 None,
9580 ),
9581 ];
9582
9583 for (node_error, status, code, retryable, statement_index) in cases {
9584 let response = node_error_response(node_error);
9585 assert_eq!(response.status(), status);
9586 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9587 .await
9588 .unwrap();
9589 let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
9590 let error: super::ClientErrorResponse = serde_json::from_value(value.clone()).unwrap();
9591 assert_eq!(error.code, code);
9592 assert_eq!(error.retryable, retryable);
9593 assert_eq!(error.statement_index, statement_index);
9594 assert!(value.get("category").is_none());
9595 assert!(matches!(
9596 error.message.as_str(),
9597 "request could not be processed"
9598 | "request conflicts with current state"
9599 | "service is temporarily unavailable"
9600 | "internal server error"
9601 ));
9602 }
9603 }
9604
9605 #[cfg(feature = "sql")]
9606 #[tokio::test]
9607 async fn node_error_http_response_hides_display_details() {
9608 let detail = "/srv/rhiza/private/consensus/log: checksum mismatch";
9609 let cases = [
9610 (
9611 NodeError::Storage(detail.into()),
9612 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
9613 "storage_error",
9614 None,
9615 "internal server error",
9616 ),
9617 (
9618 NodeError::Reconciliation(detail.into()),
9619 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
9620 "reconciliation_error",
9621 None,
9622 "internal server error",
9623 ),
9624 (
9625 NodeError::InvalidSqlStatement {
9626 statement_index: 7,
9627 message: detail.into(),
9628 },
9629 axum::http::StatusCode::BAD_REQUEST,
9630 "invalid_request",
9631 Some(7),
9632 "request could not be processed",
9633 ),
9634 ];
9635
9636 for (node_error, status, code, statement_index, message) in cases {
9637 let response = node_error_response(node_error);
9638 assert_eq!(response.status(), status);
9639 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9640 .await
9641 .unwrap();
9642 let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
9643 assert_eq!(error.code, code);
9644 assert_eq!(error.statement_index, statement_index);
9645 assert_eq!(error.message, message);
9646 assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
9647 }
9648 }
9649
9650 #[tokio::test]
9651 async fn client_json_error_response_uses_a_stable_message() {
9652 for (status, code) in [
9653 (axum::http::StatusCode::BAD_REQUEST, "invalid_json"),
9654 (
9655 axum::http::StatusCode::PAYLOAD_TOO_LARGE,
9656 "payload_too_large",
9657 ),
9658 ] {
9659 let response = super::client_json_error_response(status);
9660
9661 assert_eq!(response.status(), status);
9662 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9663 .await
9664 .unwrap();
9665 let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
9666 assert_eq!(error.code, code);
9667 assert!(!error.retryable);
9668 assert_eq!(error.message, "request body is invalid");
9669 assert_eq!(error.statement_index, None);
9670 }
9671 }
9672
9673 #[tokio::test]
9674 async fn client_task_error_hides_join_error_details() {
9675 let detail = "private task detail\nforged log entry";
9676 let error = tokio::spawn(async move { panic!("{detail}") })
9677 .await
9678 .unwrap_err();
9679 let response = super::client_task_error(error);
9680
9681 assert_eq!(
9682 response.status(),
9683 axum::http::StatusCode::INTERNAL_SERVER_ERROR
9684 );
9685 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9686 .await
9687 .unwrap();
9688 let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
9689 assert_eq!(error.code, "task_failed");
9690 assert!(!error.retryable);
9691 assert_eq!(error.message, "request task failed");
9692 assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
9693 }
9694
9695 #[cfg(feature = "sql")]
9696 #[tokio::test]
9697 async fn fatal_readiness_response_hides_fatal_reason() {
9698 let (_dir, runtime) = sql_test_runtime();
9699 let detail = "/srv/rhiza/private\nforged log entry";
9700 runtime.latch(NodeError::Storage(detail.into()));
9701 let response = super::runtime_readiness_response(&runtime).unwrap();
9702
9703 assert_eq!(
9704 response.status(),
9705 axum::http::StatusCode::INTERNAL_SERVER_ERROR
9706 );
9707 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9708 .await
9709 .unwrap();
9710 let error: super::ClientErrorResponse = serde_json::from_slice(&body).unwrap();
9711 assert_eq!(error.code, "fatal");
9712 assert!(!error.retryable);
9713 assert_eq!(error.message, "node is fatally unavailable");
9714 assert!(!String::from_utf8(body.to_vec()).unwrap().contains(detail));
9715 }
9716
9717 #[test]
9718 fn escaped_error_detail_escapes_control_characters() {
9719 let detail = "checksum mismatch\nforged entry\r\u{1b}[2J";
9720
9721 assert_eq!(
9722 super::escaped_error_detail(&detail),
9723 r"checksum mismatch\nforged entry\r\u{1b}[2J"
9724 );
9725 }
9726
9727 #[test]
9728 fn escaped_error_detail_bounds_escape_expansion() {
9729 let detail = "\n".repeat(super::MAX_ESCAPED_ERROR_DETAIL_BYTES / 2 + 1);
9730 let escaped = super::escaped_error_detail(&detail);
9731
9732 assert!(
9733 escaped.len() <= super::MAX_ESCAPED_ERROR_DETAIL_BYTES,
9734 "escaped detail must stay within the log budget"
9735 );
9736 assert!(
9737 escaped.ends_with(super::ESCAPED_ERROR_DETAIL_TRUNCATION_MARKER),
9738 "truncated details must be explicit"
9739 );
9740 assert!(!escaped.contains('\n'));
9741 }
9742
9743 #[tokio::test]
9744 async fn client_error_responses_preserve_payload_and_authentication_wire_codes() {
9745 for (status, code, retryable, category) in [
9746 (
9747 axum::http::StatusCode::PAYLOAD_TOO_LARGE,
9748 "payload_too_large",
9749 false,
9750 ErrorCategory::ResourceExhausted,
9751 ),
9752 (
9753 axum::http::StatusCode::UNAUTHORIZED,
9754 "unauthorized",
9755 false,
9756 ErrorCategory::Authentication,
9757 ),
9758 ] {
9759 let response =
9760 super::client_error_response(status, code, retryable, "request failed", None);
9761 assert_eq!(response.status(), status);
9762 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9763 .await
9764 .unwrap();
9765 let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
9766 let error: super::ClientErrorResponse = serde_json::from_value(value.clone()).unwrap();
9767 assert_eq!(error.code, code);
9768 assert_eq!(error.retryable, retryable);
9769 assert!(value.get("category").is_none());
9770 assert_eq!(
9771 ErrorClassification::from_server_code(code, retryable).category(),
9772 category
9773 );
9774 }
9775 }
9776
9777 #[test]
9778 fn concurrent_read_barriers_registered_before_cutoff_share_one_generation() {
9779 let rounds = ReadBarrierRounds::new(Duration::ZERO);
9780 let cancelled = AtomicBool::new(false);
9781 let participants = (0..4).map(|_| rounds.join().unwrap()).collect::<Vec<_>>();
9782 let generation = participants[0].generation();
9783 assert!(participants[0].is_leader());
9784 assert!(participants[1..]
9785 .iter()
9786 .all(|participant| !participant.is_leader() && participant.generation() == generation));
9787
9788 let calls = AtomicUsize::new(0);
9789 let mut publication = participants[0].publication().unwrap();
9790 publication.wait_turn(&cancelled).unwrap();
9791 publication.start(&cancelled).unwrap();
9792 let anchor = LogAnchor::new(7, LogHash::digest(&[b"shared-barrier"]));
9793 calls.fetch_add(1, Ordering::Relaxed);
9794 publication.publish(Ok(anchor));
9795
9796 assert_eq!(calls.load(Ordering::Relaxed), 1);
9797 for participant in &participants[1..] {
9798 assert_eq!(participant.wait(&cancelled).unwrap(), anchor);
9799 }
9800 }
9801
9802 #[test]
9803 fn read_barrier_arriving_after_running_cutoff_uses_next_generation() {
9804 let rounds = Arc::new(ReadBarrierRounds::new(Duration::ZERO));
9805 let cancelled = Arc::new(AtomicBool::new(false));
9806 let first = rounds.join().unwrap();
9807 let first_generation = first.generation();
9808 let (running_tx, running_rx) = mpsc::channel();
9809 let (release_tx, release_rx) = mpsc::channel();
9810 let first_cancelled = Arc::clone(&cancelled);
9811 let first_worker = std::thread::spawn(move || {
9812 let mut publication = first.publication().unwrap();
9813 publication.wait_turn(&first_cancelled).unwrap();
9814 publication.start(&first_cancelled).unwrap();
9815 running_tx.send(()).unwrap();
9816 release_rx.recv().unwrap();
9817 let anchor = LogAnchor::new(1, LogHash::digest(&[b"first"]));
9818 publication.publish(Ok(anchor));
9819 anchor
9820 });
9821 running_rx.recv().unwrap();
9822
9823 let late = rounds.join().unwrap();
9824 assert!(late.is_leader());
9825 assert_eq!(late.generation(), first_generation + 1);
9826 release_tx.send(()).unwrap();
9827 assert_eq!(first_worker.join().unwrap().index(), 1);
9828
9829 let mut publication = late.publication().unwrap();
9830 publication.wait_turn(&cancelled).unwrap();
9831 publication.start(&cancelled).unwrap();
9832 let second = LogAnchor::new(2, LogHash::digest(&[b"second"]));
9833 publication.publish(Ok(second));
9834 assert_eq!(late.wait(&cancelled).unwrap(), second);
9835 }
9836
9837 #[test]
9838 fn completed_read_barrier_is_not_reused_and_predecessor_failure_retries_independently() {
9839 let rounds = ReadBarrierRounds::new(Duration::ZERO);
9840 let cancelled = AtomicBool::new(false);
9841 let failed = rounds.join().unwrap();
9842 let failed_generation = failed.generation();
9843 let mut publication = failed.publication().unwrap();
9844 publication.wait_turn(&cancelled).unwrap();
9845 publication.start(&cancelled).unwrap();
9846 publication.publish(Err(NodeError::Unavailable("no quorum".into())));
9847 assert!(matches!(
9848 failed.wait(&cancelled),
9849 Err(NodeError::Unavailable(_))
9850 ));
9851
9852 let retry = rounds.join().unwrap();
9853 assert!(retry.is_leader());
9854 assert_eq!(retry.generation(), failed_generation + 1);
9855 let mut publication = retry.publication().unwrap();
9856 publication.wait_turn(&cancelled).unwrap();
9857 publication.start(&cancelled).unwrap();
9858 let anchor = LogAnchor::new(1, LogHash::digest(&[b"retry"]));
9859 publication.publish(Ok(anchor));
9860 assert_eq!(retry.wait(&cancelled).unwrap(), anchor);
9861
9862 let later = rounds.join().unwrap();
9863 assert!(later.is_leader());
9864 assert_eq!(later.generation(), retry.generation() + 1);
9865 }
9866
9867 #[test]
9868 fn read_barrier_leader_drop_and_global_cancel_wake_waiters() {
9869 let rounds = Arc::new(ReadBarrierRounds::new(Duration::ZERO));
9870 let cancelled = Arc::new(AtomicBool::new(false));
9871 let abandoned = rounds.join().unwrap();
9872 let follower = rounds.join().unwrap();
9873 drop(abandoned.publication().unwrap());
9874 assert!(matches!(
9875 follower.wait(&cancelled),
9876 Err(NodeError::Unavailable(_))
9877 ));
9878
9879 let leader = rounds.join().unwrap();
9880 let waiting = rounds.join().unwrap();
9881 let waiting_cancelled = Arc::clone(&cancelled);
9882 let waiter = std::thread::spawn(move || waiting.wait(&waiting_cancelled));
9883 cancelled.store(true, Ordering::Release);
9884 rounds.cancel_waiters();
9885 assert!(matches!(
9886 waiter.join().unwrap(),
9887 Err(NodeError::Unavailable(_))
9888 ));
9889 drop(leader.publication().unwrap());
9890 }
9891
9892 #[test]
9893 fn sql_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
9894 let (_dir, mut runtime) = sql_test_runtime();
9895 runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
9896 let runtime = Arc::new(runtime);
9897 let start = Arc::new(Barrier::new(4));
9898 let workers = (0..4)
9899 .map(|_| {
9900 let runtime = Arc::clone(&runtime);
9901 let start = Arc::clone(&start);
9902 std::thread::spawn(move || {
9903 start.wait();
9904 runtime.read("missing", ReadConsistency::ReadBarrier)
9905 })
9906 })
9907 .collect::<Vec<_>>();
9908
9909 let responses = workers
9910 .into_iter()
9911 .map(|worker| worker.join().unwrap().unwrap())
9912 .collect::<Vec<_>>();
9913 assert!(responses
9914 .iter()
9915 .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
9916 assert_eq!(runtime.log_store().last_index().unwrap(), None);
9917 }
9918
9919 #[test]
9920 fn read_barrier_anchor_remains_valid_when_materialized_tip_advances() {
9921 let (_dir, runtime) = sql_test_runtime();
9922 let anchor = runtime.establish_read_barrier().unwrap();
9923 let write = runtime.write("request-1", "alpha", "one").unwrap();
9924 assert!(write.applied_index > anchor.index());
9925
9926 let _commit = runtime.lock_commit().unwrap();
9927 runtime.ensure_ready().unwrap();
9928 runtime.ensure_writes_active().unwrap();
9929 runtime
9930 .validate_read_barrier_descendant_locked(anchor)
9931 .unwrap();
9932 let read = runtime.read_local("alpha", Some(anchor.index())).unwrap();
9933
9934 assert_eq!(read.value.as_deref(), Some("one"));
9935 assert_eq!(read.applied_index, write.applied_index);
9936 assert_eq!(read.hash, write.hash);
9937 }
9938
9939 #[cfg(feature = "graph")]
9940 #[test]
9941 fn graph_read_barrier_checks_materialized_tip_once_before_snapshot() {
9942 let (_dir, runtime) = graph_test_runtime();
9943
9944 let response = runtime
9945 .get_graph_document("missing", ReadConsistency::ReadBarrier)
9946 .unwrap();
9947
9948 assert_eq!(response.applied_index, 0);
9949 assert_eq!(response.hash, LogHash::ZERO);
9950 assert_eq!(runtime.materialized_tip_checks.load(Ordering::Relaxed), 1);
9951 }
9952
9953 #[cfg(feature = "graph")]
9954 #[test]
9955 fn graph_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
9956 let (_dir, mut runtime) = graph_test_runtime();
9957 runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
9958 let runtime = Arc::new(runtime);
9959 let start = Arc::new(Barrier::new(4));
9960 let workers = (0..4)
9961 .map(|_| {
9962 let runtime = Arc::clone(&runtime);
9963 let start = Arc::clone(&start);
9964 std::thread::spawn(move || {
9965 start.wait();
9966 runtime.get_graph_document("missing", ReadConsistency::ReadBarrier)
9967 })
9968 })
9969 .collect::<Vec<_>>();
9970
9971 let responses = workers
9972 .into_iter()
9973 .map(|worker| worker.join().unwrap().unwrap())
9974 .collect::<Vec<_>>();
9975 assert!(responses
9976 .iter()
9977 .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
9978 assert_eq!(runtime.log_store().last_index().unwrap(), None);
9979 }
9980
9981 #[cfg(feature = "graph")]
9982 #[test]
9983 fn graph_read_barrier_releases_commit_lock_before_backend_snapshot() {
9984 let (_dir, mut runtime) = graph_test_runtime();
9985 let initial = runtime
9986 .mutate_graph(
9987 GraphCommandV1::put_document(
9988 "request-1",
9989 "document-1",
9990 GraphValueV1::String("one".into()),
9991 )
9992 .unwrap(),
9993 )
9994 .unwrap();
9995 let entered = Arc::new(Barrier::new(2));
9996 let release = Arc::new(Barrier::new(2));
9997 runtime.read_barrier_before_snapshot_hook = Some(Arc::new({
9998 let entered = Arc::clone(&entered);
9999 let release = Arc::clone(&release);
10000 move || {
10001 entered.wait();
10002 release.wait();
10003 }
10004 }));
10005 let runtime = Arc::new(runtime);
10006 let reader = {
10007 let runtime = Arc::clone(&runtime);
10008 std::thread::spawn(move || {
10009 runtime.get_graph_document("document-1", ReadConsistency::ReadBarrier)
10010 })
10011 };
10012 entered.wait();
10013 let (advanced_tx, advanced_rx) = mpsc::channel();
10014 let writer = {
10015 let runtime = Arc::clone(&runtime);
10016 std::thread::spawn(move || {
10017 let outcome = runtime
10018 .mutate_graph(
10019 GraphCommandV1::put_document(
10020 "request-2",
10021 "document-1",
10022 GraphValueV1::String("two".into()),
10023 )
10024 .unwrap(),
10025 )
10026 .unwrap();
10027 advanced_tx
10028 .send((outcome.applied_index(), outcome.hash()))
10029 .unwrap();
10030 outcome
10031 })
10032 };
10033
10034 let advanced_before_snapshot = advanced_rx.recv_timeout(Duration::from_secs(2));
10035 release.wait();
10036 let written = writer.join().unwrap();
10037 let read = reader.join().unwrap().unwrap();
10038
10039 assert!(
10040 advanced_before_snapshot.is_ok(),
10041 "graph write must advance while the read is paused before its backend snapshot"
10042 );
10043 assert!(read.applied_index >= initial.applied_index());
10044 if read.applied_index == initial.applied_index() {
10045 assert_eq!(read.hash, initial.hash());
10046 }
10047 assert_eq!(read.applied_index, written.applied_index());
10048 assert_eq!(read.hash, written.hash());
10049 }
10050
10051 #[cfg(feature = "graph")]
10052 #[test]
10053 fn read_barrier_rejects_same_index_snapshot_with_different_hash() {
10054 let (_dir, runtime) = graph_test_runtime();
10055 let anchor = LogAnchor::new(7, LogHash::digest(&[b"barrier-anchor"]));
10056 let observed = LogAnchor::new(7, LogHash::digest(&[b"divergent-snapshot"]));
10057
10058 assert!(matches!(
10059 runtime.validate_read_barrier_snapshot(anchor, observed),
10060 Err(NodeError::Invariant(message))
10061 if message.contains("snapshot tip hash differs")
10062 ));
10063 assert!(runtime.is_fatal());
10064 }
10065
10066 #[cfg(feature = "kv")]
10067 #[test]
10068 fn kv_read_barrier_checks_materialized_tip_once_before_snapshot() {
10069 let (_dir, runtime) = kv_test_runtime();
10070
10071 let response = runtime
10072 .get_kv(b"missing", ReadConsistency::ReadBarrier)
10073 .unwrap();
10074
10075 assert_eq!(response.applied_index, 0);
10076 assert_eq!(response.hash, LogHash::ZERO);
10077 assert_eq!(runtime.materialized_tip_checks.load(Ordering::Relaxed), 1);
10078 }
10079
10080 #[cfg(feature = "kv")]
10081 #[test]
10082 fn kv_c4_read_barrier_shares_one_qlog_anchor_and_preserves_snapshot_tip() {
10083 let (_dir, mut runtime) = kv_test_runtime();
10084 runtime.read_barriers = ReadBarrierRounds::new(Duration::from_millis(20));
10085 let runtime = Arc::new(runtime);
10086 let start = Arc::new(Barrier::new(4));
10087 let workers = (0..4)
10088 .map(|_| {
10089 let runtime = Arc::clone(&runtime);
10090 let start = Arc::clone(&start);
10091 std::thread::spawn(move || {
10092 start.wait();
10093 runtime.get_kv(b"missing", ReadConsistency::ReadBarrier)
10094 })
10095 })
10096 .collect::<Vec<_>>();
10097
10098 let responses = workers
10099 .into_iter()
10100 .map(|worker| worker.join().unwrap().unwrap())
10101 .collect::<Vec<_>>();
10102 assert!(responses
10103 .iter()
10104 .all(|response| response.applied_index == 0 && response.hash == LogHash::ZERO));
10105 assert_eq!(runtime.log_store().last_index().unwrap(), None);
10106 }
10107
10108 #[cfg(feature = "kv")]
10109 #[test]
10110 fn kv_read_barrier_releases_commit_lock_before_backend_snapshot() {
10111 let (_dir, mut runtime) = kv_test_runtime();
10112 let initial = runtime
10113 .mutate_kv(KvCommandV1::put("request-1", b"key".to_vec(), b"one".to_vec()).unwrap())
10114 .unwrap();
10115 let entered = Arc::new(Barrier::new(2));
10116 let release = Arc::new(Barrier::new(2));
10117 runtime.read_barrier_before_snapshot_hook = Some(Arc::new({
10118 let entered = Arc::clone(&entered);
10119 let release = Arc::clone(&release);
10120 move || {
10121 entered.wait();
10122 release.wait();
10123 }
10124 }));
10125 let runtime = Arc::new(runtime);
10126 let reader = {
10127 let runtime = Arc::clone(&runtime);
10128 std::thread::spawn(move || runtime.get_kv(b"key", ReadConsistency::ReadBarrier))
10129 };
10130 entered.wait();
10131 let (advanced_tx, advanced_rx) = mpsc::channel();
10132 let writer = {
10133 let runtime = Arc::clone(&runtime);
10134 std::thread::spawn(move || {
10135 let outcome = runtime
10136 .mutate_kv(
10137 KvCommandV1::put("request-2", b"key".to_vec(), b"two".to_vec()).unwrap(),
10138 )
10139 .unwrap();
10140 advanced_tx
10141 .send((outcome.applied_index(), outcome.hash()))
10142 .unwrap();
10143 outcome
10144 })
10145 };
10146
10147 let advanced_before_snapshot = advanced_rx.recv_timeout(Duration::from_secs(2));
10148 release.wait();
10149 let written = writer.join().unwrap();
10150 let read = reader.join().unwrap().unwrap();
10151
10152 assert!(
10153 advanced_before_snapshot.is_ok(),
10154 "KV write must advance while the read is paused before its backend snapshot"
10155 );
10156 assert!(read.applied_index >= initial.applied_index());
10157 if read.applied_index == initial.applied_index() {
10158 assert_eq!(read.hash, initial.hash());
10159 }
10160 assert_eq!(read.applied_index, written.applied_index());
10161 assert_eq!(read.hash, written.hash());
10162 }
10163
10164 #[test]
10165 fn client_authentication_rejects_empty_expected_token() {
10166 let mut headers = HeaderMap::new();
10167 headers.insert(VERSION_HEADER, HeaderValue::from_static(PROTOCOL_VERSION));
10168 headers.insert("authorization", HeaderValue::from_static("Bearer "));
10169
10170 assert!(!client_authenticated(&headers, ""));
10171 }
10172
10173 #[test]
10174 fn recorder_record_rejects_oversized_inline_command() {
10175 let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
10176 let command = StoredCommand::new(
10177 EntryType::Command,
10178 vec![0_u8; MAX_COMMAND_BYTES.saturating_add(1)],
10179 );
10180 let request = RecordRequest {
10181 cluster_id: "rhiza:sql:node-unit-test".into(),
10182 epoch: 1,
10183 config_id: 1,
10184 config_digest: membership.digest(),
10185 slot: 1,
10186 step: 1,
10187 proposal: Proposal::new(
10188 ProposalPriority::MAX,
10189 "n1",
10190 1,
10191 AcceptedValue::from_command(
10192 "rhiza:sql:node-unit-test",
10193 1,
10194 1,
10195 1,
10196 LogHash::ZERO,
10197 &command,
10198 ),
10199 ),
10200 command: Some(command),
10201 };
10202
10203 assert!(!valid_recorder_record(&request));
10204 }
10205
10206 #[test]
10207 fn sync_flush_retry_doubles_to_a_jitter_free_cap() {
10208 let mut delay = SYNC_FLUSH_RETRY_INITIAL;
10209 let mut delays = Vec::new();
10210 for _ in 0..7 {
10211 delays.push(delay);
10212 delay = next_sync_flush_retry(delay);
10213 }
10214
10215 assert_eq!(
10216 delays,
10217 [50, 100, 200, 400, 800, 1_000, 1_000].map(Duration::from_millis)
10218 );
10219 }
10220
10221 #[test]
10222 fn blocking_operation_offloads_on_current_thread_runtime() {
10223 let runtime = tokio::runtime::Builder::new_current_thread()
10224 .build()
10225 .unwrap();
10226 runtime.block_on(async {
10227 let caller = std::thread::current().id();
10228 let worker = run_read_operation(ReadConsistency::Local, || std::thread::current().id())
10229 .await
10230 .unwrap();
10231
10232 assert_ne!(worker, caller);
10233 });
10234 }
10235
10236 #[test]
10237 fn blocking_operation_runs_inline_on_multi_thread_runtime() {
10238 let runtime = tokio::runtime::Builder::new_multi_thread()
10239 .worker_threads(2)
10240 .build()
10241 .unwrap();
10242 runtime.block_on(async {
10243 let caller = std::thread::current().id();
10244 let worker = run_read_operation(ReadConsistency::AppliedIndex(1), || {
10245 std::thread::current().id()
10246 })
10247 .await
10248 .unwrap();
10249
10250 assert_eq!(worker, caller);
10251 });
10252 }
10253
10254 #[test]
10255 fn read_barrier_offloads_on_multi_thread_runtime() {
10256 let runtime = tokio::runtime::Builder::new_multi_thread()
10257 .worker_threads(2)
10258 .build()
10259 .unwrap();
10260 runtime.block_on(async {
10261 let caller = std::thread::current().id();
10262 let worker =
10263 run_read_operation(ReadConsistency::ReadBarrier, || std::thread::current().id())
10264 .await
10265 .unwrap();
10266
10267 assert_ne!(worker, caller);
10268 });
10269 }
10270
10271 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10272 async fn node_service_adaptive_sql_read_returns_point_and_query_results() {
10273 let (_dir, runtime) = sql_test_runtime();
10274 let service = NodeService::new(Arc::new(runtime), None);
10275
10276 let point = service
10277 .read("missing", ReadConsistency::Local)
10278 .await
10279 .unwrap();
10280 let query = service
10281 .query(
10282 SqlStatement {
10283 sql: "SELECT ?1 AS value".into(),
10284 parameters: vec![SqlValue::Integer(7)],
10285 },
10286 ReadConsistency::AppliedIndex(0),
10287 1,
10288 )
10289 .await
10290 .unwrap();
10291
10292 assert_eq!(point.value, None);
10293 assert_eq!(query.columns, vec!["value"]);
10294 assert_eq!(query.rows, vec![vec![SqlValue::Integer(7)]]);
10295 }
10296
10297 #[test]
10298 fn node_service_adaptive_sql_read_stays_inline_and_recovers_direct_panic() {
10299 let (_dir, runtime) = sql_test_runtime();
10300 let service = NodeService::new(Arc::new(runtime), None);
10301 let runtime = tokio::runtime::Builder::new_multi_thread()
10302 .worker_threads(2)
10303 .build()
10304 .unwrap();
10305 runtime.block_on(async {
10306 let caller = std::thread::current().id();
10307 let worker = service
10308 .run_sql_read_operation(ReadConsistency::Local, || std::thread::current().id())
10309 .await
10310 .unwrap();
10311
10312 assert_eq!(worker, caller);
10313 assert_eq!(
10314 service
10315 .sql_reads_in_flight
10316 .load(std::sync::atomic::Ordering::Acquire),
10317 0
10318 );
10319 });
10320
10321 let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
10322 runtime.block_on(
10323 service.run_sql_read_operation(ReadConsistency::AppliedIndex(0), || -> () {
10324 panic!("inline SQL read panic")
10325 }),
10326 )
10327 }));
10328 assert!(panic.is_err());
10329 assert_eq!(
10330 service
10331 .sql_reads_in_flight
10332 .load(std::sync::atomic::Ordering::Acquire),
10333 0
10334 );
10335 }
10336
10337 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10338 async fn node_service_adaptive_sql_read_offloads_overlap_and_recovers_join_error() {
10339 let (_dir, runtime) = sql_test_runtime();
10340 let service = NodeService::new(Arc::new(runtime), None);
10341 let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
10342 let (release_tx, release_rx) = std::sync::mpsc::channel();
10343 let first_service = service.clone();
10344 let first = tokio::spawn(async move {
10345 first_service
10346 .run_sql_read_operation(ReadConsistency::Local, move || {
10347 entered_tx.send(()).unwrap();
10348 release_rx.recv().unwrap();
10349 })
10350 .await
10351 .unwrap();
10352 });
10353 entered_rx.await.unwrap();
10354 assert_eq!(
10355 service
10356 .sql_reads_in_flight
10357 .load(std::sync::atomic::Ordering::Acquire),
10358 1
10359 );
10360
10361 let caller = std::thread::current().id();
10362 let worker = service
10363 .run_sql_read_operation(ReadConsistency::AppliedIndex(0), || {
10364 std::thread::current().id()
10365 })
10366 .await
10367 .unwrap();
10368 assert_ne!(worker, caller);
10369 assert_eq!(
10370 service
10371 .sql_reads_in_flight
10372 .load(std::sync::atomic::Ordering::Acquire),
10373 1
10374 );
10375
10376 let error = service
10377 .run_sql_read_operation(ReadConsistency::Local, || -> () {
10378 panic!("contended SQL read panic")
10379 })
10380 .await
10381 .unwrap_err();
10382 assert!(error.is_panic());
10383 assert_eq!(
10384 service
10385 .sql_reads_in_flight
10386 .load(std::sync::atomic::Ordering::Acquire),
10387 1
10388 );
10389
10390 release_tx.send(()).unwrap();
10391 first.await.unwrap();
10392 assert_eq!(
10393 service
10394 .sql_reads_in_flight
10395 .load(std::sync::atomic::Ordering::Acquire),
10396 0
10397 );
10398 }
10399
10400 #[test]
10401 fn node_service_adaptive_sql_read_offloads_on_current_thread() {
10402 let (_dir, node) = sql_test_runtime();
10403 let service = NodeService::new(Arc::new(node), None);
10404 let runtime = tokio::runtime::Builder::new_current_thread()
10405 .build()
10406 .unwrap();
10407 runtime.block_on(async {
10408 let caller = std::thread::current().id();
10409 let worker = service
10410 .run_sql_read_operation(ReadConsistency::Local, || std::thread::current().id())
10411 .await
10412 .unwrap();
10413
10414 assert_ne!(worker, caller);
10415 assert_eq!(
10416 service
10417 .sql_reads_in_flight
10418 .load(std::sync::atomic::Ordering::Acquire),
10419 0
10420 );
10421 });
10422 }
10423
10424 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10425 async fn node_service_read_barrier_offloads_without_counting_fast_reads() {
10426 let (_dir, runtime) = sql_test_runtime();
10427 let service = NodeService::new(Arc::new(runtime), None);
10428 let caller = std::thread::current().id();
10429 let worker = service
10430 .run_sql_read_operation(ReadConsistency::ReadBarrier, || std::thread::current().id())
10431 .await
10432 .unwrap();
10433
10434 assert_ne!(worker, caller);
10435 assert_eq!(
10436 service
10437 .sql_reads_in_flight
10438 .load(std::sync::atomic::Ordering::Acquire),
10439 0
10440 );
10441 }
10442
10443 #[test]
10444 fn embedded_sql_query_keeps_raw_budget_without_http_json_budget() {
10445 let (_dir, runtime) = sql_test_runtime();
10446 let response = runtime
10447 .query_sql(
10448 &SqlStatement {
10449 sql: "SELECT replace(hex(zeroblob(700000)), '00', char(1)) AS value".into(),
10450 parameters: Vec::new(),
10451 },
10452 ReadConsistency::Local,
10453 1,
10454 )
10455 .unwrap();
10456
10457 assert!(serde_json::to_vec(&response).unwrap().len() > MAX_SQL_RESPONSE_BYTES);
10458 }
10459
10460 #[test]
10461 fn sql_http_response_rejects_encoded_body_over_limit() {
10462 let response = SqlQueryResponse {
10463 columns: vec!["value".into()],
10464 rows: vec![vec![SqlValue::Text("\u{1}".repeat(700_000))]],
10465 applied_index: 0,
10466 hash: LogHash::ZERO,
10467 };
10468
10469 assert_eq!(
10470 sql_query_http_response(response).status(),
10471 axum::http::StatusCode::BAD_REQUEST
10472 );
10473 }
10474
10475 #[cfg(feature = "graph")]
10476 #[test]
10477 fn graph_response_work_holds_client_capacity_until_completion() {
10478 let slots = std::sync::Arc::new(tokio::sync::Semaphore::new(1));
10479 let permit = std::sync::Arc::new(slots.clone().try_acquire_owned().unwrap());
10480
10481 let capacity_exhausted_during_response =
10482 with_graph_client_permit(permit, || slots.clone().try_acquire_owned().is_err());
10483
10484 assert!(capacity_exhausted_during_response);
10485 assert!(slots.try_acquire().is_ok());
10486 }
10487
10488 #[cfg(feature = "graph")]
10489 #[test]
10490 fn graph_client_query_error_returns_400_without_latching_readiness() {
10491 let (_dir, runtime) = graph_test_runtime();
10492
10493 let error = runtime.map_graph_read_error(rhiza_graph::Error::InvalidCommand(
10494 "unknown property".into(),
10495 ));
10496 let response = node_error_response(error);
10497
10498 assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
10499 assert!(runtime.is_ready());
10500 assert!(!runtime.is_fatal());
10501 }
10502
10503 #[cfg(feature = "graph")]
10504 #[test]
10505 fn graph_resource_exhaustion_returns_503_without_latching_readiness() {
10506 let (_dir, runtime) = graph_test_runtime();
10507
10508 let error = runtime.map_graph_read_error(rhiza_graph::Error::ResourceExhausted(
10509 "buffer pool is full".into(),
10510 ));
10511 let response = node_error_response(error);
10512
10513 assert_eq!(
10514 response.status(),
10515 axum::http::StatusCode::SERVICE_UNAVAILABLE
10516 );
10517 assert!(runtime.is_ready());
10518 assert!(!runtime.is_fatal());
10519 }
10520
10521 #[cfg(feature = "kv")]
10522 #[test]
10523 fn kv_resource_exhaustion_returns_503_without_latching_readiness() {
10524 let (_dir, runtime) = kv_test_runtime();
10525
10526 let error = runtime.map_kv_read_error(rhiza_kv::Error::ResourceExhausted(
10527 "scan result is too large".into(),
10528 ));
10529 let response = node_error_response(error);
10530
10531 assert_eq!(
10532 response.status(),
10533 axum::http::StatusCode::SERVICE_UNAVAILABLE
10534 );
10535 assert!(runtime.is_ready());
10536 assert!(!runtime.is_fatal());
10537 }
10538
10539 #[cfg(feature = "graph")]
10540 #[test]
10541 fn graph_batch_coalesces_exact_retry_and_isolates_conflicting_duplicate() {
10542 let (_dir, runtime) = graph_test_runtime();
10543 let canonical =
10544 GraphCommandV1::put_document("same", "document", GraphValueV1::String("first".into()))
10545 .unwrap();
10546 let conflict = GraphCommandV1::put_document(
10547 "same",
10548 "document",
10549 GraphValueV1::String("conflict".into()),
10550 )
10551 .unwrap();
10552 let unrelated =
10553 GraphCommandV1::put_document("other", "other", GraphValueV1::U64(2)).unwrap();
10554 let results = runtime
10555 .mutate_graph_batch(vec![canonical.clone(), canonical, conflict, unrelated])
10556 .unwrap();
10557
10558 let canonical = results[0].as_ref().unwrap().applied_index();
10559 assert_eq!(results[1].as_ref().unwrap().applied_index(), canonical);
10560 assert!(matches!(
10561 results[2],
10562 Err(super::NodeError::InvalidRequest(_))
10563 ));
10564 assert_eq!(results[3].as_ref().unwrap().applied_index(), canonical);
10565 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
10566 assert!(runtime.is_ready());
10567 }
10568
10569 #[cfg(feature = "kv")]
10570 #[test]
10571 fn kv_batch_coalesces_exact_retry_and_isolates_conflicting_duplicate() {
10572 let (_dir, runtime) = kv_test_runtime();
10573 let canonical = KvCommandV1::put("same", b"key".to_vec(), b"first".to_vec()).unwrap();
10574 let conflict = KvCommandV1::put("same", b"key".to_vec(), b"conflict".to_vec()).unwrap();
10575 let unrelated = KvCommandV1::put("other", b"other".to_vec(), b"second".to_vec()).unwrap();
10576 let results = runtime
10577 .mutate_kv_batch(vec![canonical.clone(), canonical, conflict, unrelated])
10578 .unwrap();
10579
10580 let canonical = results[0].as_ref().unwrap().applied_index();
10581 assert_eq!(results[1].as_ref().unwrap().applied_index(), canonical);
10582 assert!(matches!(
10583 results[2],
10584 Err(super::NodeError::InvalidRequest(_))
10585 ));
10586 assert_eq!(results[3].as_ref().unwrap().applied_index(), canonical);
10587 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
10588 assert!(runtime.is_ready());
10589 }
10590
10591 #[cfg(feature = "kv")]
10592 #[test]
10593 fn kv_group_commit_coalesces_four_waiting_64_member_calls_into_one_qlog() {
10594 let (_dir, runtime) = kv_test_runtime();
10595 let runtime = Arc::new(runtime);
10596 let commit = runtime.lock_commit().unwrap();
10597 let start = Arc::new(Barrier::new(5));
10598 let workers = (0..4)
10599 .map(|call| {
10600 let runtime = Arc::clone(&runtime);
10601 let start = Arc::clone(&start);
10602 std::thread::spawn(move || {
10603 let commands = (0..64)
10604 .map(|member| {
10605 let id = call * 64 + member;
10606 KvCommandV1::put(
10607 format!("kv-group-{id}"),
10608 format!("key-{id}").into_bytes(),
10609 vec![u8::try_from(call).unwrap(); 128],
10610 )
10611 .unwrap()
10612 })
10613 .collect();
10614 start.wait();
10615 runtime.mutate_kv_batch(commands)
10616 })
10617 })
10618 .collect::<Vec<_>>();
10619 start.wait();
10620 runtime
10621 .kv_group_commit
10622 .wait_for_pending_calls(4, Duration::from_secs(5));
10623 drop(commit);
10624
10625 let responses = workers
10626 .into_iter()
10627 .map(|worker| worker.join().unwrap().unwrap())
10628 .collect::<Vec<_>>();
10629 let anchors = responses
10630 .iter()
10631 .flatten()
10632 .map(|result| {
10633 let outcome = result.as_ref().unwrap();
10634 (outcome.applied_index(), outcome.hash())
10635 })
10636 .collect::<std::collections::HashSet<_>>();
10637 assert_eq!(anchors.len(), 1);
10638 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
10639 }
10640
10641 #[cfg(feature = "kv")]
10642 #[test]
10643 fn kv_group_commit_rejects_public_257_member_call_before_writing() {
10644 let (_dir, runtime) = kv_test_runtime();
10645 let commands = (0..257)
10646 .map(|id| {
10647 KvCommandV1::put(
10648 format!("kv-over-{id}"),
10649 format!("key-{id}").into_bytes(),
10650 b"value".to_vec(),
10651 )
10652 .unwrap()
10653 })
10654 .collect();
10655
10656 let error = runtime.mutate_kv_batch(commands).unwrap_err();
10657
10658 assert!(matches!(error, NodeError::InvalidRequest(_)));
10659 assert_eq!(runtime.log_store().last_index().unwrap(), None);
10660 assert!(runtime
10661 .kv_group_commit
10662 .state
10663 .lock()
10664 .unwrap()
10665 .pending
10666 .is_empty());
10667 }
10668
10669 #[cfg(feature = "kv")]
10670 #[test]
10671 fn kv_group_commit_lone_call_completes_and_leaves_queue_idle() {
10672 let (_dir, runtime) = kv_test_runtime();
10673
10674 let outcome = runtime
10675 .mutate_kv(KvCommandV1::put("kv-lone", b"key".to_vec(), b"value".to_vec()).unwrap())
10676 .unwrap();
10677
10678 assert_eq!(outcome.applied_index(), 1);
10679 let state = runtime
10680 .kv_group_commit
10681 .state
10682 .lock()
10683 .unwrap_or_else(std::sync::PoisonError::into_inner);
10684 assert!(state.pending.is_empty());
10685 assert_eq!(state.pending_encoded_bytes, 0);
10686 assert!(!state.leader_active);
10687 }
10688
10689 #[cfg(feature = "kv")]
10690 #[test]
10691 fn kv_group_commit_shutdown_wakes_waiters_without_writing() {
10692 let (_dir, runtime) = kv_test_runtime();
10693 let runtime = Arc::new(runtime);
10694 let commit = runtime.lock_commit().unwrap();
10695 let start = Arc::new(Barrier::new(3));
10696 let workers = (0..2)
10697 .map(|id| {
10698 let runtime = Arc::clone(&runtime);
10699 let start = Arc::clone(&start);
10700 std::thread::spawn(move || {
10701 start.wait();
10702 runtime.mutate_kv(
10703 KvCommandV1::put(
10704 format!("kv-shutdown-{id}"),
10705 format!("key-{id}").into_bytes(),
10706 b"value".to_vec(),
10707 )
10708 .unwrap(),
10709 )
10710 })
10711 })
10712 .collect::<Vec<_>>();
10713 start.wait();
10714 runtime
10715 .kv_group_commit
10716 .wait_for_pending_calls(2, Duration::from_secs(5));
10717
10718 runtime.cancel_operations();
10719 drop(commit);
10720
10721 for worker in workers {
10722 assert!(matches!(
10723 worker.join().unwrap(),
10724 Err(NodeError::Unavailable(_))
10725 ));
10726 }
10727 assert_eq!(runtime.log_store().last_index().unwrap(), None);
10728 let state = runtime
10729 .kv_group_commit
10730 .state
10731 .lock()
10732 .unwrap_or_else(std::sync::PoisonError::into_inner);
10733 assert!(state.pending.is_empty());
10734 assert_eq!(state.pending_encoded_bytes, 0);
10735 assert!(!state.leader_active);
10736 }
10737
10738 #[cfg(feature = "kv")]
10739 #[test]
10740 fn kv_group_commit_preserves_cross_call_retry_conflict_and_new_result_offsets() {
10741 let (_dir, runtime) = kv_test_runtime();
10742 let runtime = Arc::new(runtime);
10743 let stored = KvCommandV1::put(
10744 "kv-stored",
10745 b"stored-key".to_vec(),
10746 b"stored-value".to_vec(),
10747 )
10748 .unwrap();
10749 let stored_outcome = runtime.mutate_kv(stored.clone()).unwrap();
10750 let conflict =
10751 KvCommandV1::put("kv-stored", b"stored-key".to_vec(), b"conflict".to_vec()).unwrap();
10752 let commit = runtime.lock_commit().unwrap();
10753 let start = Arc::new(Barrier::new(3));
10754 let retry_worker = {
10755 let runtime = Arc::clone(&runtime);
10756 let start = Arc::clone(&start);
10757 std::thread::spawn(move || {
10758 start.wait();
10759 runtime.mutate_kv_batch(vec![stored, conflict])
10760 })
10761 };
10762 let new_worker = {
10763 let runtime = Arc::clone(&runtime);
10764 let start = Arc::clone(&start);
10765 std::thread::spawn(move || {
10766 start.wait();
10767 runtime.mutate_kv_batch(vec![
10768 KvCommandV1::put("kv-new-1", b"new-1".to_vec(), b"one".to_vec()).unwrap(),
10769 KvCommandV1::put("kv-new-2", b"new-2".to_vec(), b"two".to_vec()).unwrap(),
10770 ])
10771 })
10772 };
10773 start.wait();
10774 runtime
10775 .kv_group_commit
10776 .wait_for_pending_calls(2, Duration::from_secs(5));
10777 drop(commit);
10778
10779 let retry_results = retry_worker.join().unwrap().unwrap();
10780 let new_results = new_worker.join().unwrap().unwrap();
10781
10782 assert_eq!(
10783 retry_results[0].as_ref().unwrap().applied_index(),
10784 stored_outcome.applied_index()
10785 );
10786 assert!(matches!(
10787 retry_results[1],
10788 Err(NodeError::InvalidRequest(_))
10789 ));
10790 let new_anchors = new_results
10791 .iter()
10792 .map(|result| {
10793 let outcome = result.as_ref().unwrap();
10794 (outcome.applied_index(), outcome.hash())
10795 })
10796 .collect::<std::collections::HashSet<_>>();
10797 assert_eq!(new_anchors.len(), 1);
10798 assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
10799 }
10800
10801 #[cfg(feature = "kv")]
10802 #[test]
10803 fn kv_group_commit_releases_pending_byte_budget_after_drain_and_failure() {
10804 let queue = super::KvGroupCommitQueue::new();
10805 let cancelled = AtomicBool::new(false);
10806 let member = |id: usize, bytes: usize| super::RuntimeBatchMember {
10807 #[cfg(feature = "sql")]
10808 request_id: format!("kv-byte-{id}"),
10809 payload: vec![u8::try_from(id).unwrap_or_default(); bytes],
10810 operation: super::QueuedOperation::Kv(
10811 KvCommandV1::put(
10812 format!("kv-byte-{id}"),
10813 format!("key-{id}").into_bytes(),
10814 b"value".to_vec(),
10815 )
10816 .unwrap(),
10817 ),
10818 };
10819 for id in 0..63 {
10820 queue
10821 .enqueue(vec![member(id, MAX_COMMAND_BYTES)], &cancelled)
10822 .unwrap();
10823 }
10824
10825 let overflow = match queue.enqueue(vec![member(63, MAX_COMMAND_BYTES * 2)], &cancelled) {
10826 Ok(_) => panic!("pending KV byte budget must reject oversized aggregate work"),
10827 Err(error) => error,
10828 };
10829 assert!(matches!(overflow, NodeError::ResourceExhausted(_)));
10830
10831 let drained = queue.drain_next_group().unwrap();
10832 assert_eq!(drained.len(), 63);
10833 let released = queue
10834 .enqueue(vec![member(64, MAX_COMMAND_BYTES)], &cancelled)
10835 .unwrap()
10836 .0;
10837 queue.fail_pending(NodeError::Unavailable("test failure".into()));
10838 assert!(matches!(
10839 released.wait(&cancelled),
10840 Err(NodeError::Unavailable(_))
10841 ));
10842 let state = queue
10843 .state
10844 .lock()
10845 .unwrap_or_else(std::sync::PoisonError::into_inner);
10846 assert!(state.pending.is_empty());
10847 assert_eq!(state.pending_encoded_bytes, 0);
10848 assert!(!state.leader_active);
10849 }
10850
10851 #[cfg(feature = "kv")]
10852 #[test]
10853 fn kv_group_commit_window_restarts_after_staggered_enqueue() {
10854 let queue = Arc::new(super::KvGroupCommitQueue::new());
10855 let cancelled = AtomicBool::new(false);
10856 let member = |id: usize| super::RuntimeBatchMember {
10857 #[cfg(feature = "sql")]
10858 request_id: format!("kv-debounce-{id}"),
10859 payload: vec![u8::try_from(id).unwrap_or_default()],
10860 operation: super::QueuedOperation::Kv(
10861 KvCommandV1::put(
10862 format!("kv-debounce-{id}"),
10863 format!("key-{id}").into_bytes(),
10864 b"value".to_vec(),
10865 )
10866 .unwrap(),
10867 ),
10868 };
10869 queue.enqueue(vec![member(1)], &cancelled).unwrap();
10870 let collector = Arc::clone(&queue);
10871 let (finished, receive) = std::sync::mpsc::channel();
10872 let worker = std::thread::spawn(move || {
10873 let collected = collector.collect_until_full_or_timeout(Duration::from_millis(100));
10874 finished.send(collected).unwrap();
10875 });
10876
10877 std::thread::sleep(Duration::from_millis(75));
10878 queue.enqueue(vec![member(2)], &cancelled).unwrap();
10879 std::thread::sleep(Duration::from_millis(75));
10880 queue.enqueue(vec![member(3)], &cancelled).unwrap();
10881 assert!(receive.recv_timeout(Duration::from_millis(50)).is_err());
10882 assert!(receive.recv_timeout(Duration::from_millis(150)).unwrap());
10883 worker.join().unwrap();
10884 assert_eq!(queue.drain_next_group().unwrap().len(), 3);
10885 }
10886
10887 #[cfg(feature = "kv")]
10888 #[test]
10889 fn kv_group_commit_returns_committed_group_when_cancelled_after_execution() {
10890 let (_dir, mut runtime) = kv_test_runtime();
10891 runtime.kv_group_commit_after_execute_hook = Some(Arc::new(NodeRuntime::cancel_operations));
10892 let runtime = Arc::new(runtime);
10893 let commit = runtime.lock_commit().unwrap();
10894 let start = Arc::new(Barrier::new(3));
10895 let workers = (0..2)
10896 .map(|call| {
10897 let runtime = Arc::clone(&runtime);
10898 let start = Arc::clone(&start);
10899 std::thread::spawn(move || {
10900 let commands = (0..64)
10901 .map(|member| {
10902 let id = call * 64 + member;
10903 KvCommandV1::put(
10904 format!("kv-cancel-after-{id}"),
10905 format!("key-{id}").into_bytes(),
10906 b"value".to_vec(),
10907 )
10908 .unwrap()
10909 })
10910 .collect();
10911 start.wait();
10912 runtime.mutate_kv_batch(commands)
10913 })
10914 })
10915 .collect::<Vec<_>>();
10916 start.wait();
10917 runtime
10918 .kv_group_commit
10919 .wait_for_pending_calls(2, Duration::from_secs(5));
10920 drop(commit);
10921
10922 let results = workers
10923 .into_iter()
10924 .flat_map(|worker| worker.join().unwrap().unwrap())
10925 .collect::<Vec<_>>();
10926
10927 assert_eq!(results.len(), 128);
10928 let anchors = results
10929 .iter()
10930 .map(|result| {
10931 let outcome = result.as_ref().unwrap();
10932 (outcome.applied_index(), outcome.hash())
10933 })
10934 .collect::<std::collections::HashSet<_>>();
10935 assert_eq!(anchors.len(), 1);
10936 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
10937 assert!(runtime.operation_cancelled.load(Ordering::Acquire));
10938 }
10939
10940 #[cfg(feature = "kv")]
10941 #[test]
10942 fn kv_largest_fitting_prefix_is_exact_for_large_grouped_batch() {
10943 let commands = (0..256)
10944 .map(|id| {
10945 KvCommandV1::put(
10946 format!("kv-prefix-{id:04}"),
10947 format!("key-{id:04}").into_bytes(),
10948 vec![b'x'; 4 * 1024],
10949 )
10950 .unwrap()
10951 })
10952 .collect::<Vec<_>>();
10953 assert!(super::encode_replicated_kv_batch(&commands).unwrap().len() > MAX_COMMAND_BYTES);
10954 let expected = (2..commands.len())
10955 .filter(|count| {
10956 super::encode_replicated_kv_batch(&commands[..*count])
10957 .unwrap()
10958 .len()
10959 <= MAX_COMMAND_BYTES
10960 })
10961 .max()
10962 .unwrap();
10963
10964 let (count, payload) = super::largest_fitting_kv_batch_prefix(&commands).unwrap();
10965
10966 assert_eq!(count, expected);
10967 assert!(payload.len() <= MAX_COMMAND_BYTES);
10968 assert!(
10969 super::encode_replicated_kv_batch(&commands[..count + 1])
10970 .unwrap()
10971 .len()
10972 > MAX_COMMAND_BYTES
10973 );
10974 }
10975
10976 #[cfg(feature = "kv")]
10977 #[test]
10978 fn kv_group_commit_large_batch_uses_largest_fitting_fifo_sub_batches() {
10979 let (_dir, runtime) = kv_test_runtime();
10980 let runtime = Arc::new(runtime);
10981 let calls = (0..4)
10982 .map(|call| {
10983 (0..64)
10984 .map(|member| {
10985 let id = call * 64 + member;
10986 KvCommandV1::put(
10987 format!("kv-large-{id:04}"),
10988 format!("key-{id:04}").into_bytes(),
10989 vec![b'x'; 4 * 1024],
10990 )
10991 .unwrap()
10992 })
10993 .collect::<Vec<_>>()
10994 })
10995 .collect::<Vec<_>>();
10996 let flattened = calls.iter().flatten().cloned().collect::<Vec<_>>();
10997 let (largest_prefix, _) = super::largest_fitting_kv_batch_prefix(&flattened).unwrap();
10998 let expected_entries = flattened.len().div_ceil(largest_prefix);
10999 let commit = runtime.lock_commit().unwrap();
11000 let start = Arc::new(Barrier::new(5));
11001 let workers = calls
11002 .into_iter()
11003 .map(|commands| {
11004 let runtime = Arc::clone(&runtime);
11005 let start = Arc::clone(&start);
11006 std::thread::spawn(move || {
11007 start.wait();
11008 runtime.mutate_kv_batch(commands)
11009 })
11010 })
11011 .collect::<Vec<_>>();
11012 start.wait();
11013 runtime
11014 .kv_group_commit
11015 .wait_for_pending_calls(4, Duration::from_secs(5));
11016 drop(commit);
11017
11018 let mut counts = std::collections::BTreeMap::new();
11019 for result in workers
11020 .into_iter()
11021 .flat_map(|worker| worker.join().unwrap().unwrap())
11022 {
11023 *counts
11024 .entry(result.unwrap().applied_index())
11025 .or_insert(0_usize) += 1;
11026 }
11027
11028 assert_eq!(counts.len(), expected_entries);
11029 let counts = counts.into_values().collect::<Vec<_>>();
11030 assert!(counts[..counts.len() - 1]
11031 .iter()
11032 .all(|count| *count == largest_prefix));
11033 assert_eq!(counts.iter().sum::<usize>(), 256);
11034 assert_eq!(
11035 runtime.log_store().last_index().unwrap(),
11036 Some(u64::try_from(expected_entries).unwrap())
11037 );
11038 for index in 1..=u64::try_from(expected_entries).unwrap() {
11039 assert!(runtime
11040 .log_store()
11041 .read(index)
11042 .unwrap()
11043 .is_some_and(|entry| entry.payload.len() <= MAX_COMMAND_BYTES));
11044 }
11045 }
11046
11047 #[cfg(feature = "kv")]
11048 #[test]
11049 fn kv_group_commit_leader_panic_wakes_active_and_pending_calls_with_same_fatal() {
11050 let (_dir, mut runtime) = kv_test_runtime();
11051 runtime.kv_group_commit_before_execute_hook =
11052 Some(Arc::new(|| panic!("injected KV group leader panic")));
11053 let runtime = Arc::new(runtime);
11054 let commit = runtime.lock_commit().unwrap();
11055 let mut workers = Vec::new();
11056 for call in 0..17 {
11057 let worker_runtime = Arc::clone(&runtime);
11058 workers.push(std::thread::spawn(move || {
11059 worker_runtime.mutate_kv_batch(
11060 (0..64)
11061 .map(|member| {
11062 let id = call * 64 + member;
11063 KvCommandV1::put(
11064 format!("kv-panic-{id}"),
11065 format!("key-{id}").into_bytes(),
11066 b"value".to_vec(),
11067 )
11068 .unwrap()
11069 })
11070 .collect(),
11071 )
11072 }));
11073 runtime
11074 .kv_group_commit
11075 .wait_for_pending_calls(call + 1, Duration::from_secs(5));
11076 }
11077 drop(commit);
11078
11079 let errors = workers
11080 .into_iter()
11081 .map(|worker| worker.join().unwrap().unwrap_err())
11082 .collect::<Vec<_>>();
11083 assert!(errors
11084 .iter()
11085 .all(|error| matches!(error, NodeError::Fatal(_))));
11086 assert_eq!(errors[0].to_string(), errors[1].to_string());
11087 assert!(runtime.is_fatal());
11088 assert_eq!(runtime.log_store().last_index().unwrap(), None);
11089 }
11090
11091 #[cfg(feature = "kv")]
11092 #[test]
11093 fn kv_group_commit_reopens_all_four_grouped_calls_at_shared_durable_anchor() {
11094 let (dir, runtime) = kv_test_runtime();
11095 let runtime = Arc::new(runtime);
11096 let commit = runtime.lock_commit().unwrap();
11097 let start = Arc::new(Barrier::new(5));
11098 let workers = (0..4)
11099 .map(|call| {
11100 let runtime = Arc::clone(&runtime);
11101 let start = Arc::clone(&start);
11102 std::thread::spawn(move || {
11103 let commands = (0..64)
11104 .map(|member| {
11105 let id = call * 64 + member;
11106 KvCommandV1::put(
11107 format!("kv-reopen-{id}"),
11108 format!("key-{id:04}").into_bytes(),
11109 vec![u8::try_from(call).unwrap(); 128],
11110 )
11111 .unwrap()
11112 })
11113 .collect();
11114 start.wait();
11115 runtime.mutate_kv_batch(commands)
11116 })
11117 })
11118 .collect::<Vec<_>>();
11119 start.wait();
11120 runtime
11121 .kv_group_commit
11122 .wait_for_pending_calls(4, Duration::from_secs(5));
11123 drop(commit);
11124
11125 let results = workers
11126 .into_iter()
11127 .flat_map(|worker| worker.join().unwrap().unwrap())
11128 .collect::<Vec<_>>();
11129 let anchors = results
11130 .iter()
11131 .map(|result| {
11132 let outcome = result.as_ref().unwrap();
11133 (outcome.applied_index(), outcome.hash())
11134 })
11135 .collect::<std::collections::HashSet<_>>();
11136 let [(applied_index, applied_hash)] = anchors.into_iter().collect::<Vec<_>>()[..] else {
11137 panic!("four grouped calls must share one durable anchor");
11138 };
11139 assert_eq!(
11140 runtime.log_store().last_index().unwrap(),
11141 Some(applied_index)
11142 );
11143 let config = runtime.config().clone();
11144 drop(runtime);
11145
11146 let consensus = Arc::new(
11147 ThreeNodeConsensus::from_recovered_tip(
11148 config.cluster_id().to_owned(),
11149 config.node_id().to_owned(),
11150 config.epoch(),
11151 config.config_id(),
11152 [
11153 dir.path().join("recorders/n1"),
11154 dir.path().join("recorders/n2"),
11155 dir.path().join("recorders/n3"),
11156 ],
11157 applied_index + 1,
11158 applied_hash,
11159 )
11160 .unwrap(),
11161 );
11162 let reopened = NodeRuntime::open(config, consensus, &[]).unwrap();
11163
11164 assert_eq!(reopened.applied_index().unwrap(), applied_index);
11165 assert_eq!(reopened.applied_hash().unwrap(), applied_hash);
11166 assert_eq!(reopened.log_store().last_index().unwrap(), Some(1));
11167 for id in 0..256 {
11168 let response = reopened
11169 .get_kv(format!("key-{id:04}").as_bytes(), ReadConsistency::Local)
11170 .unwrap();
11171 assert_eq!(
11172 response.value,
11173 Some(vec![u8::try_from(id / 64).unwrap(); 128])
11174 );
11175 assert_eq!(response.applied_index, applied_index);
11176 assert_eq!(response.hash, applied_hash);
11177 }
11178 }
11179
11180 #[test]
11181 fn sql_batch_preflight_rejects_entire_vector_without_growing_log() {
11182 let (_dir, runtime) = sql_test_runtime();
11183 let valid = SqlCommand {
11184 request_id: "valid".into(),
11185 statements: vec![SqlStatement {
11186 sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY)".into(),
11187 parameters: vec![],
11188 }],
11189 };
11190 let invalid = SqlCommand {
11191 request_id: String::new(),
11192 statements: valid.statements.clone(),
11193 };
11194
11195 let error = runtime.execute_sql_batch(vec![valid, invalid]).unwrap_err();
11196
11197 assert!(matches!(error, NodeError::InvalidRequest(_)));
11198 assert_eq!(runtime.log_store().last_index().unwrap(), None);
11199 }
11200
11201 #[test]
11202 fn sql_batch_rejects_aggregate_encoded_input_over_command_cap_before_io() {
11203 let (_dir, runtime) = sql_test_runtime();
11204 let command = |request_id: &str, fill: char| SqlCommand {
11205 request_id: request_id.into(),
11206 statements: vec![SqlStatement {
11207 sql: "SELECT ?1".into(),
11208 parameters: vec![SqlValue::Text(
11209 std::iter::repeat_n(fill, MAX_COMMAND_BYTES / 2).collect(),
11210 )],
11211 }],
11212 };
11213
11214 let error = runtime
11215 .execute_sql_batch(vec![
11216 command("aggregate-a", 'a'),
11217 command("aggregate-b", 'b'),
11218 ])
11219 .unwrap_err();
11220
11221 assert!(matches!(error, NodeError::ResourceExhausted(_)));
11222 assert_eq!(runtime.log_store().last_index().unwrap(), None);
11223 assert!(runtime
11224 .sql_group_commit
11225 .state
11226 .lock()
11227 .unwrap()
11228 .pending
11229 .is_empty());
11230 }
11231
11232 #[test]
11233 fn sql_write_profiling_records_nothing_when_observer_is_not_installed() {
11234 let profiler = SqlWriteProfiler::new(8);
11235 let (_dir, runtime) = sql_test_runtime();
11236
11237 runtime
11238 .execute_sql(SqlCommand {
11239 request_id: "schema".into(),
11240 statements: vec![SqlStatement {
11241 sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11242 parameters: vec![],
11243 }],
11244 })
11245 .unwrap();
11246
11247 assert!(runtime.config().sql_write_profiler().is_none());
11248 assert!(profiler.snapshot().samples.is_empty());
11249 }
11250
11251 #[test]
11252 fn sql_write_profiling_records_one_consistent_sample_for_one_physical_batch() {
11253 let profiler = SqlWriteProfiler::new(8);
11254 let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11255 runtime
11256 .execute_sql(SqlCommand {
11257 request_id: "schema".into(),
11258 statements: vec![SqlStatement {
11259 sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11260 parameters: vec![],
11261 }],
11262 })
11263 .unwrap();
11264 profiler.drain();
11265
11266 let commands = (1..=3)
11267 .map(|id| SqlCommand {
11268 request_id: format!("insert-{id}"),
11269 statements: vec![SqlStatement {
11270 sql: "INSERT INTO profiled_items(id) VALUES (?1)".into(),
11271 parameters: vec![SqlValue::Integer(id)],
11272 }],
11273 })
11274 .collect();
11275 let responses = runtime.execute_sql_batch(commands).unwrap();
11276
11277 let snapshot = profiler.snapshot();
11278 assert_eq!(snapshot.dropped_samples, 0);
11279 let [sample] = snapshot.samples.as_slice() else {
11280 panic!("one physical SQL batch must emit one sample: {snapshot:?}");
11281 };
11282 assert_eq!(sample.batch_member_count, 3);
11283 assert_eq!(
11284 sample.total_service_us,
11285 sample
11286 .commit_lock_wait_us
11287 .saturating_add(sample.precheck_classification_us)
11288 .saturating_add(sample.qwal_prepare_us)
11289 .saturating_add(sample.consensus_propose_us)
11290 .saturating_add(sample.local_qlog_mirror_append_us)
11291 .saturating_add(sample.sql_materializer_apply_us)
11292 .saturating_add(sample.response_other_total_us)
11293 );
11294 let (applied_index, applied_hash) = runtime.ensure_materialized_tip().unwrap();
11295 assert!(responses.iter().all(|response| {
11296 response.as_ref().is_ok_and(|response| {
11297 response.applied_index == applied_index && response.hash == applied_hash
11298 })
11299 }));
11300 }
11301
11302 #[test]
11303 fn sql_write_profiling_does_not_fabricate_sample_for_failed_batch() {
11304 let profiler = SqlWriteProfiler::new(8);
11305 let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11306 runtime
11307 .execute_sql(SqlCommand {
11308 request_id: "schema".into(),
11309 statements: vec![SqlStatement {
11310 sql: "CREATE TABLE profiled_items(id INTEGER PRIMARY KEY)".into(),
11311 parameters: vec![],
11312 }],
11313 })
11314 .unwrap();
11315 runtime
11316 .execute_sql(SqlCommand {
11317 request_id: "first".into(),
11318 statements: vec![SqlStatement {
11319 sql: "INSERT INTO profiled_items(id) VALUES (1)".into(),
11320 parameters: vec![],
11321 }],
11322 })
11323 .unwrap();
11324 profiler.drain();
11325 let last_index = runtime.log_store().last_index().unwrap();
11326
11327 let error = runtime
11328 .execute_sql(SqlCommand {
11329 request_id: "duplicate".into(),
11330 statements: vec![SqlStatement {
11331 sql: "INSERT INTO profiled_items(id) VALUES (1)".into(),
11332 parameters: vec![],
11333 }],
11334 })
11335 .unwrap_err();
11336
11337 assert!(matches!(error, NodeError::InvalidSqlStatement { .. }));
11338 assert!(profiler.snapshot().samples.is_empty());
11339 assert_eq!(runtime.log_store().last_index().unwrap(), last_index);
11340 }
11341
11342 #[test]
11343 fn sql_group_commit_coalesces_four_waiting_typed_calls_into_one_1024_receipt_qwal() {
11344 let profiler = SqlWriteProfiler::new(8);
11345 let (_dir, runtime) = sql_test_runtime_with_profiler(Some(profiler.clone()));
11346 let runtime = Arc::new(runtime);
11347 runtime
11348 .execute_sql(SqlCommand {
11349 request_id: "group-schema".into(),
11350 statements: vec![SqlStatement {
11351 sql: "CREATE TABLE grouped_items(id INTEGER PRIMARY KEY)".into(),
11352 parameters: vec![],
11353 }],
11354 })
11355 .unwrap();
11356 profiler.drain();
11357
11358 let commit = runtime.lock_commit().unwrap();
11359 let start = Arc::new(Barrier::new(5));
11360 let workers = (0..4)
11361 .map(|call| {
11362 let runtime = Arc::clone(&runtime);
11363 let start = Arc::clone(&start);
11364 std::thread::spawn(move || {
11365 let commands = (0..256)
11366 .map(|offset| {
11367 let id = call * 256 + offset;
11368 SqlCommand {
11369 request_id: format!("group-{id}"),
11370 statements: vec![SqlStatement {
11371 sql: "INSERT INTO grouped_items(id) VALUES (?1)".into(),
11372 parameters: vec![SqlValue::Integer(id)],
11373 }],
11374 }
11375 })
11376 .collect();
11377 start.wait();
11378 runtime.execute_sql_batch(commands).unwrap()
11379 })
11380 })
11381 .collect::<Vec<_>>();
11382 start.wait();
11383 runtime
11384 .sql_group_commit
11385 .wait_for_pending_calls(4, Duration::from_secs(5));
11386 drop(commit);
11387
11388 let results = workers
11389 .into_iter()
11390 .flat_map(|worker| worker.join().unwrap())
11391 .collect::<Vec<_>>();
11392 assert_eq!(results.len(), 1024);
11393 let first = results[0].as_ref().unwrap();
11394 assert!(results.iter().all(|result| {
11395 result.as_ref().is_ok_and(|result| {
11396 result.applied_index == first.applied_index && result.hash == first.hash
11397 })
11398 }));
11399 let entry = runtime
11400 .log_store()
11401 .read(first.applied_index)
11402 .unwrap()
11403 .unwrap();
11404 assert_eq!(
11405 rhiza_sql::decode_qwal_v3(&entry.payload)
11406 .unwrap()
11407 .receipts
11408 .len(),
11409 1024
11410 );
11411 assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11412 let snapshot = profiler.snapshot();
11413 let [sample] = snapshot.samples.as_slice() else {
11414 panic!("one grouped physical commit must emit one sample: {snapshot:?}");
11415 };
11416 assert_eq!(sample.batch_member_count, 1024);
11417 }
11418
11419 #[test]
11420 fn sql_group_commit_keeps_fifth_whole_call_for_the_next_physical_group() {
11421 let (_dir, runtime) = sql_test_runtime();
11422 let runtime = Arc::new(runtime);
11423 runtime
11424 .execute_sql(SqlCommand {
11425 request_id: "next-group-schema".into(),
11426 statements: vec![SqlStatement {
11427 sql: "CREATE TABLE next_group_items(id INTEGER PRIMARY KEY)".into(),
11428 parameters: vec![],
11429 }],
11430 })
11431 .unwrap();
11432
11433 let commit = runtime.lock_commit().unwrap();
11434 let mut workers = Vec::new();
11435 for call in 0..5 {
11436 let worker_runtime = Arc::clone(&runtime);
11437 workers.push(std::thread::spawn(move || {
11438 worker_runtime
11439 .execute_sql_batch(
11440 (0..256)
11441 .map(|offset| {
11442 let id = call * 256 + offset;
11443 SqlCommand {
11444 request_id: format!("next-group-{id}"),
11445 statements: vec![SqlStatement {
11446 sql: "INSERT INTO next_group_items(id) VALUES (?1)".into(),
11447 parameters: vec![SqlValue::Integer(id)],
11448 }],
11449 }
11450 })
11451 .collect(),
11452 )
11453 .unwrap()
11454 }));
11455 runtime
11456 .sql_group_commit
11457 .wait_for_pending_calls(call as usize + 1, Duration::from_secs(5));
11458 }
11459 drop(commit);
11460
11461 let calls = workers
11462 .into_iter()
11463 .map(|worker| worker.join().unwrap())
11464 .collect::<Vec<_>>();
11465 for call in &calls[..4] {
11466 assert!(call
11467 .iter()
11468 .all(|result| result.as_ref().unwrap().applied_index == 2));
11469 }
11470 assert!(calls[4]
11471 .iter()
11472 .all(|result| result.as_ref().unwrap().applied_index == 3));
11473 assert_eq!(
11474 rhiza_sql::decode_qwal_v3(&runtime.log_store().read(2).unwrap().unwrap().payload)
11475 .unwrap()
11476 .receipts
11477 .len(),
11478 1024
11479 );
11480 assert_eq!(
11481 rhiza_sql::decode_qwal_v3(&runtime.log_store().read(3).unwrap().unwrap().payload)
11482 .unwrap()
11483 .receipts
11484 .len(),
11485 256
11486 );
11487 }
11488
11489 #[test]
11490 fn sql_group_commit_preserves_fifo_call_offsets_for_retries_conflicts_aliases_and_failures() {
11491 let (_dir, runtime) = sql_test_runtime();
11492 let runtime = Arc::new(runtime);
11493 runtime
11494 .execute_sql(SqlCommand {
11495 request_id: "fifo-schema".into(),
11496 statements: vec![SqlStatement {
11497 sql: "CREATE TABLE fifo_items(id INTEGER PRIMARY KEY, value TEXT UNIQUE)"
11498 .into(),
11499 parameters: vec![],
11500 }],
11501 })
11502 .unwrap();
11503 let stored_command = SqlCommand {
11504 request_id: "fifo-stored".into(),
11505 statements: vec![SqlStatement {
11506 sql: "INSERT INTO fifo_items(id, value) VALUES (1, 'stored')".into(),
11507 parameters: vec![],
11508 }],
11509 };
11510 let stored = runtime.execute_sql(stored_command.clone()).unwrap();
11511 let valid_alias = SqlCommand {
11512 request_id: "fifo-alias".into(),
11513 statements: vec![SqlStatement {
11514 sql: "INSERT INTO fifo_items(id, value) VALUES (2, 'alias')".into(),
11515 parameters: vec![],
11516 }],
11517 };
11518 let conflict = SqlCommand {
11519 request_id: stored_command.request_id.clone(),
11520 statements: vec![SqlStatement {
11521 sql: "INSERT INTO fifo_items(id, value) VALUES (3, 'conflict')".into(),
11522 parameters: vec![],
11523 }],
11524 };
11525 let failed = SqlCommand {
11526 request_id: "fifo-failed".into(),
11527 statements: vec![SqlStatement {
11528 sql: "INSERT INTO fifo_items(id, value) VALUES (4, 'stored')".into(),
11529 parameters: vec![],
11530 }],
11531 };
11532 let valid = SqlCommand {
11533 request_id: "fifo-valid".into(),
11534 statements: vec![SqlStatement {
11535 sql: "INSERT INTO fifo_items(id, value) VALUES (5, 'valid')".into(),
11536 parameters: vec![],
11537 }],
11538 };
11539
11540 let commit = runtime.lock_commit().unwrap();
11541 let first_runtime = Arc::clone(&runtime);
11542 let first = std::thread::spawn(move || {
11543 first_runtime
11544 .execute_sql_batch(vec![
11545 stored_command,
11546 conflict,
11547 valid_alias.clone(),
11548 valid_alias,
11549 ])
11550 .unwrap()
11551 });
11552 runtime
11553 .sql_group_commit
11554 .wait_for_pending_calls(1, Duration::from_secs(5));
11555 let second_runtime = Arc::clone(&runtime);
11556 let second = std::thread::spawn(move || {
11557 second_runtime
11558 .execute_sql_batch(vec![failed, valid])
11559 .unwrap()
11560 });
11561 runtime
11562 .sql_group_commit
11563 .wait_for_pending_calls(2, Duration::from_secs(5));
11564 drop(commit);
11565
11566 let first = first.join().unwrap();
11567 let second = second.join().unwrap();
11568 assert_eq!(
11569 first[0].as_ref().unwrap().applied_index,
11570 stored.applied_index
11571 );
11572 assert!(matches!(first[1], Err(NodeError::RequestConflict(_))));
11573 assert_eq!(first[2], first[3]);
11574 assert_eq!(first[2].as_ref().unwrap().applied_index, 3);
11575 assert!(matches!(
11576 second[0],
11577 Err(NodeError::InvalidSqlStatement { .. })
11578 ));
11579 assert_eq!(second[1].as_ref().unwrap().applied_index, 3);
11580 }
11581
11582 #[test]
11583 fn sql_group_commit_rejects_overload_before_enqueue_without_orphaning_the_leader() {
11584 let (_dir, runtime) = sql_test_runtime_configured(None, Some(1));
11585 let runtime = Arc::new(runtime);
11586 runtime
11587 .execute_sql(SqlCommand {
11588 request_id: "overload-schema".into(),
11589 statements: vec![SqlStatement {
11590 sql: "CREATE TABLE overload_items(id INTEGER PRIMARY KEY)".into(),
11591 parameters: vec![],
11592 }],
11593 })
11594 .unwrap();
11595 let command = |request_id: &str, id| SqlCommand {
11596 request_id: request_id.into(),
11597 statements: vec![SqlStatement {
11598 sql: "INSERT INTO overload_items(id) VALUES (?1)".into(),
11599 parameters: vec![SqlValue::Integer(id)],
11600 }],
11601 };
11602
11603 let commit = runtime.lock_commit().unwrap();
11604 let leader_runtime = Arc::clone(&runtime);
11605 let leader = std::thread::spawn(move || {
11606 leader_runtime.execute_sql_batch(vec![command("overload-first", 1)])
11607 });
11608 runtime
11609 .sql_group_commit
11610 .wait_for_pending_calls(1, Duration::from_secs(5));
11611 let overload = runtime
11612 .execute_sql_batch(vec![command("overload-second", 2)])
11613 .unwrap_err();
11614 assert!(matches!(overload, NodeError::ResourceExhausted(_)));
11615 drop(commit);
11616 assert!(leader.join().unwrap().unwrap()[0].is_ok());
11617 assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11618 }
11619
11620 #[test]
11621 fn sql_group_commit_bounds_pending_bytes_and_releases_reservations() {
11622 let queue = super::SqlGroupCommitQueue::new(super::MAX_SQL_GROUP_COMMIT_QUEUE_CAPACITY);
11623 let cancelled = AtomicBool::new(false);
11624 let member = |id: usize| super::RuntimeBatchMember {
11625 request_id: format!("queued-{id}"),
11626 payload: vec![u8::try_from(id).unwrap_or_default(); MAX_COMMAND_BYTES],
11627 operation: super::QueuedOperation::Sql(SqlCommand {
11628 request_id: format!("queued-{id}"),
11629 statements: vec![SqlStatement {
11630 sql: "SELECT 1".into(),
11631 parameters: vec![],
11632 }],
11633 }),
11634 };
11635 let mut queued = Vec::new();
11636 for id in 0..super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY {
11637 queued.push(queue.enqueue(vec![member(id)], &cancelled).unwrap().0);
11638 }
11639
11640 let overflow = match queue.enqueue(
11641 vec![member(super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY)],
11642 &cancelled,
11643 ) {
11644 Ok(_) => panic!("pending byte budget must reject one more full command"),
11645 Err(error) => error,
11646 };
11647 assert!(matches!(overflow, NodeError::ResourceExhausted(_)));
11648
11649 let drained = queue.drain_next_group().unwrap();
11650 assert_eq!(drained.len(), 4);
11651 let released = queue
11652 .enqueue(
11653 vec![member(super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY + 1)],
11654 &cancelled,
11655 )
11656 .unwrap()
11657 .0;
11658
11659 queue.fail_pending(NodeError::Unavailable("test failure".into()));
11660 for job in queued.into_iter().skip(drained.len()) {
11661 assert!(matches!(
11662 job.wait(&cancelled),
11663 Err(NodeError::Unavailable(_))
11664 ));
11665 }
11666 assert!(matches!(
11667 released.wait(&cancelled),
11668 Err(NodeError::Unavailable(_))
11669 ));
11670 let state = queue
11671 .state
11672 .lock()
11673 .unwrap_or_else(std::sync::PoisonError::into_inner);
11674 assert!(state.pending.is_empty());
11675 assert_eq!(state.pending_encoded_bytes, 0);
11676 assert!(!state.leader_active);
11677 }
11678
11679 #[test]
11680 fn sql_group_commit_window_restarts_after_staggered_enqueue() {
11681 let queue = Arc::new(super::SqlGroupCommitQueue::new(
11682 super::DEFAULT_SQL_GROUP_COMMIT_QUEUE_CAPACITY,
11683 ));
11684 let cancelled = AtomicBool::new(false);
11685 let member = |id: usize| super::RuntimeBatchMember {
11686 request_id: format!("sql-debounce-{id}"),
11687 payload: vec![u8::try_from(id).unwrap_or_default()],
11688 operation: super::QueuedOperation::Sql(SqlCommand {
11689 request_id: format!("sql-debounce-{id}"),
11690 statements: vec![SqlStatement {
11691 sql: "SELECT 1".into(),
11692 parameters: vec![],
11693 }],
11694 }),
11695 };
11696 queue.enqueue(vec![member(1)], &cancelled).unwrap();
11697 let collector = Arc::clone(&queue);
11698 let (finished, receive) = std::sync::mpsc::channel();
11699 let worker = std::thread::spawn(move || {
11700 let collected = collector.collect_until_full_or_timeout(Duration::from_millis(100));
11701 finished.send(collected).unwrap();
11702 });
11703
11704 std::thread::sleep(Duration::from_millis(75));
11705 queue.enqueue(vec![member(2)], &cancelled).unwrap();
11706 std::thread::sleep(Duration::from_millis(75));
11707 queue.enqueue(vec![member(3)], &cancelled).unwrap();
11708 assert!(receive.recv_timeout(Duration::from_millis(50)).is_err());
11709 assert!(receive.recv_timeout(Duration::from_millis(150)).unwrap());
11710 worker.join().unwrap();
11711 assert_eq!(queue.drain_next_group().unwrap().len(), 3);
11712 }
11713
11714 #[test]
11715 fn sql_group_commit_leader_panic_wakes_every_queued_call_with_the_same_fatal_error() {
11716 let (_dir, mut runtime) = sql_test_runtime();
11717 runtime
11718 .execute_sql(SqlCommand {
11719 request_id: "panic-schema".into(),
11720 statements: vec![SqlStatement {
11721 sql: "CREATE TABLE panic_items(id INTEGER PRIMARY KEY)".into(),
11722 parameters: vec![],
11723 }],
11724 })
11725 .unwrap();
11726 runtime.sql_group_commit_before_execute_hook =
11727 Some(Arc::new(|| panic!("injected SQL group leader panic")));
11728 let runtime = Arc::new(runtime);
11729 let commit = runtime.lock_commit().unwrap();
11730 let mut workers = Vec::new();
11731 for id in 1..=2 {
11732 let worker_runtime = Arc::clone(&runtime);
11733 workers.push(std::thread::spawn(move || {
11734 worker_runtime.execute_sql_batch(vec![SqlCommand {
11735 request_id: format!("panic-{id}"),
11736 statements: vec![SqlStatement {
11737 sql: "INSERT INTO panic_items(id) VALUES (?1)".into(),
11738 parameters: vec![SqlValue::Integer(id)],
11739 }],
11740 }])
11741 }));
11742 runtime
11743 .sql_group_commit
11744 .wait_for_pending_calls(id as usize, Duration::from_secs(5));
11745 }
11746 drop(commit);
11747
11748 let errors = workers
11749 .into_iter()
11750 .map(|worker| worker.join().unwrap().unwrap_err())
11751 .collect::<Vec<_>>();
11752 assert!(errors
11753 .iter()
11754 .all(|error| matches!(error, NodeError::Fatal(_))));
11755 assert_eq!(errors[0].to_string(), errors[1].to_string());
11756 assert!(runtime.is_fatal());
11757 }
11758
11759 #[test]
11760 fn sql_group_commit_shutdown_wakes_queued_calls_with_the_same_unavailable_error() {
11761 let (_dir, runtime) = sql_test_runtime();
11762 let runtime = Arc::new(runtime);
11763 runtime
11764 .execute_sql(SqlCommand {
11765 request_id: "shutdown-schema".into(),
11766 statements: vec![SqlStatement {
11767 sql: "CREATE TABLE shutdown_items(id INTEGER PRIMARY KEY)".into(),
11768 parameters: vec![],
11769 }],
11770 })
11771 .unwrap();
11772 let command = |id| SqlCommand {
11773 request_id: format!("shutdown-{id}"),
11774 statements: vec![SqlStatement {
11775 sql: "INSERT INTO shutdown_items(id) VALUES (?1)".into(),
11776 parameters: vec![SqlValue::Integer(id)],
11777 }],
11778 };
11779
11780 let commit = runtime.lock_commit().unwrap();
11781 let leader_runtime = Arc::clone(&runtime);
11782 let leader = std::thread::spawn(move || leader_runtime.execute_sql_batch(vec![command(1)]));
11783 runtime
11784 .sql_group_commit
11785 .wait_for_pending_calls(1, Duration::from_secs(5));
11786 let follower_runtime = Arc::clone(&runtime);
11787 let follower =
11788 std::thread::spawn(move || follower_runtime.execute_sql_batch(vec![command(2)]));
11789 runtime
11790 .sql_group_commit
11791 .wait_for_pending_calls(2, Duration::from_secs(5));
11792
11793 runtime.cancel_operations();
11794 let follower_error = follower.join().unwrap().unwrap_err();
11795 drop(commit);
11796 let leader_error = leader.join().unwrap().unwrap_err();
11797
11798 assert!(matches!(leader_error, NodeError::Unavailable(_)));
11799 assert_eq!(leader_error.to_string(), follower_error.to_string());
11800 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
11801 }
11802
11803 #[test]
11804 fn sql_group_commit_reprepares_combined_calls_after_a_foreign_slot_winner() {
11805 let (_dir, runtime) = sql_test_runtime();
11806 let runtime = Arc::new(runtime);
11807 let schema = runtime
11808 .execute_sql(SqlCommand {
11809 request_id: "group-winner-schema".into(),
11810 statements: vec![SqlStatement {
11811 sql: "CREATE TABLE group_winner(id INTEGER PRIMARY KEY)".into(),
11812 parameters: vec![],
11813 }],
11814 })
11815 .unwrap();
11816 let winner = runtime
11817 .consensus()
11818 .propose_at(
11819 2,
11820 schema.hash,
11821 Command::new(CommandKind::ReadBarrier, Vec::new()),
11822 )
11823 .unwrap();
11824
11825 let commit = runtime.lock_commit().unwrap();
11826 let mut workers = Vec::new();
11827 for id in 1..=2 {
11828 let worker_runtime = Arc::clone(&runtime);
11829 workers.push(std::thread::spawn(move || {
11830 worker_runtime
11831 .execute_sql_batch(vec![SqlCommand {
11832 request_id: format!("group-winner-{id}"),
11833 statements: vec![SqlStatement {
11834 sql: "INSERT INTO group_winner(id) VALUES (?1)".into(),
11835 parameters: vec![SqlValue::Integer(id)],
11836 }],
11837 }])
11838 .unwrap()
11839 }));
11840 runtime
11841 .sql_group_commit
11842 .wait_for_pending_calls(id as usize, Duration::from_secs(5));
11843 }
11844 drop(commit);
11845
11846 let results = workers
11847 .into_iter()
11848 .flat_map(|worker| worker.join().unwrap())
11849 .collect::<Vec<_>>();
11850 assert_eq!(runtime.log_store().read(2).unwrap(), Some(winner));
11851 assert!(results
11852 .iter()
11853 .all(|result| result.as_ref().unwrap().applied_index == 3));
11854 assert_eq!(
11855 results[0].as_ref().unwrap().hash,
11856 results[1].as_ref().unwrap().hash
11857 );
11858 }
11859
11860 #[test]
11861 fn sql_group_commit_all_failed_calls_return_aligned_without_consensus() {
11862 let (_dir, runtime) = sql_test_runtime();
11863 let runtime = Arc::new(runtime);
11864 runtime
11865 .execute_sql(SqlCommand {
11866 request_id: "all-failed-schema".into(),
11867 statements: vec![SqlStatement {
11868 sql: "CREATE TABLE all_failed(value TEXT UNIQUE)".into(),
11869 parameters: vec![],
11870 }],
11871 })
11872 .unwrap();
11873 runtime
11874 .execute_sql(SqlCommand {
11875 request_id: "all-failed-existing".into(),
11876 statements: vec![SqlStatement {
11877 sql: "INSERT INTO all_failed(value) VALUES ('existing')".into(),
11878 parameters: vec![],
11879 }],
11880 })
11881 .unwrap();
11882
11883 let commit = runtime.lock_commit().unwrap();
11884 let mut workers = Vec::new();
11885 for id in 1..=2 {
11886 let worker_runtime = Arc::clone(&runtime);
11887 workers.push(std::thread::spawn(move || {
11888 worker_runtime
11889 .execute_sql_batch(vec![SqlCommand {
11890 request_id: format!("all-failed-{id}"),
11891 statements: vec![SqlStatement {
11892 sql: "INSERT INTO all_failed(value) VALUES ('existing')".into(),
11893 parameters: vec![],
11894 }],
11895 }])
11896 .unwrap()
11897 }));
11898 runtime
11899 .sql_group_commit
11900 .wait_for_pending_calls(id as usize, Duration::from_secs(5));
11901 }
11902 drop(commit);
11903
11904 for worker in workers {
11905 let results = worker.join().unwrap();
11906 assert_eq!(results.len(), 1);
11907 assert!(matches!(
11908 results[0],
11909 Err(NodeError::InvalidSqlStatement { .. })
11910 ));
11911 }
11912 assert_eq!(runtime.log_store().last_index().unwrap(), Some(2));
11913 }
11914
11915 #[test]
11916 fn sql_group_commit_lone_call_completes_after_the_bounded_collection_round() {
11917 let (_dir, runtime) = sql_test_runtime();
11918
11919 let result = runtime
11920 .execute_sql_batch(vec![SqlCommand {
11921 request_id: "lone-call".into(),
11922 statements: vec![SqlStatement {
11923 sql: "CREATE TABLE lone_call(id INTEGER PRIMARY KEY)".into(),
11924 parameters: vec![],
11925 }],
11926 }])
11927 .unwrap();
11928
11929 assert!(result[0].is_ok());
11930 let queue = runtime
11931 .sql_group_commit
11932 .state
11933 .lock()
11934 .unwrap_or_else(std::sync::PoisonError::into_inner);
11935 assert!(queue.pending.is_empty());
11936 assert!(!queue.leader_active);
11937 }
11938
11939 #[test]
11940 fn legacy_put_endpoint_commits_qwal_instead_of_raw_put_payload() {
11941 let (_dir, runtime) = sql_test_runtime();
11942
11943 let response = runtime.write("legacy-put", "key", "value").unwrap();
11944
11945 let entry = runtime
11946 .log_store()
11947 .read(response.applied_index)
11948 .unwrap()
11949 .unwrap();
11950 assert!(entry.payload.starts_with(QWAL_V3_MAGIC));
11951 assert!(!entry.payload.starts_with(b"put\t"));
11952 assert_eq!(
11953 runtime.read("key", ReadConsistency::Local).unwrap().value,
11954 Some("value".into())
11955 );
11956 }
11957
11958 #[test]
11959 fn sql_batch_preserves_order_commits_one_qwal_effect_and_retries_exactly() {
11960 let (_dir, runtime) = sql_test_runtime();
11961 runtime
11962 .execute_sql(SqlCommand {
11963 request_id: "schema".into(),
11964 statements: vec![SqlStatement {
11965 sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
11966 .into(),
11967 parameters: vec![],
11968 }],
11969 })
11970 .unwrap();
11971 let commands = (1..=3)
11972 .map(|id| SqlCommand {
11973 request_id: format!("insert-{id}"),
11974 statements: vec![SqlStatement {
11975 sql: "INSERT INTO batch_items(id, value) VALUES (?1, ?2)".into(),
11976 parameters: vec![SqlValue::Integer(id), SqlValue::Text(format!("value-{id}"))],
11977 }],
11978 })
11979 .collect::<Vec<_>>();
11980
11981 let first = runtime.execute_sql_batch(commands.clone()).unwrap();
11982 let first_indices = first
11983 .iter()
11984 .map(|result| result.as_ref().unwrap().applied_index)
11985 .collect::<Vec<_>>();
11986 let log_index = runtime.log_store().last_index().unwrap();
11987 let replay = runtime.execute_sql_batch(commands).unwrap();
11988
11989 assert_eq!(first_indices, vec![2, 2, 2]);
11990 assert_eq!(
11991 first
11992 .iter()
11993 .map(|result| result.as_ref().unwrap().hash)
11994 .collect::<std::collections::HashSet<_>>()
11995 .len(),
11996 1
11997 );
11998 for index in 1..=2 {
11999 let entry = runtime.log_store().read(index).unwrap().unwrap();
12000 assert!(entry.payload.starts_with(QWAL_V3_MAGIC));
12001 }
12002 assert_eq!(
12003 replay
12004 .iter()
12005 .map(|result| result.as_ref().unwrap().applied_index)
12006 .collect::<Vec<_>>(),
12007 first_indices
12008 );
12009 assert_eq!(runtime.log_store().last_index().unwrap(), log_index);
12010 }
12011
12012 #[test]
12013 fn sql_effect_over_qlog_limit_is_resource_exhausted() {
12014 let (_dir, runtime) = sql_test_runtime();
12015 runtime
12016 .execute_sql(SqlCommand {
12017 request_id: "schema".into(),
12018 statements: vec![SqlStatement {
12019 sql: "CREATE TABLE large_effect(value BLOB NOT NULL)".into(),
12020 parameters: vec![],
12021 }],
12022 })
12023 .unwrap();
12024
12025 let error = runtime
12026 .execute_sql(SqlCommand {
12027 request_id: "large-effect".into(),
12028 statements: vec![SqlStatement {
12029 sql: "INSERT INTO large_effect(value) VALUES (randomblob(700000))".into(),
12030 parameters: vec![],
12031 }],
12032 })
12033 .unwrap_err();
12034
12035 assert!(matches!(error, NodeError::ResourceExhausted(_)));
12036 assert_eq!(runtime.log_store().last_index().unwrap(), Some(1));
12037 }
12038
12039 #[test]
12040 fn sql_batch_isolates_request_conflict_from_unrelated_member() {
12041 let (_dir, runtime) = sql_test_runtime();
12042 runtime
12043 .execute_sql(SqlCommand {
12044 request_id: "schema".into(),
12045 statements: vec![SqlStatement {
12046 sql: "CREATE TABLE batch_items(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
12047 .into(),
12048 parameters: vec![],
12049 }],
12050 })
12051 .unwrap();
12052 let insert = |request_id: &str, id: i64| SqlCommand {
12053 request_id: request_id.into(),
12054 statements: vec![SqlStatement {
12055 sql: "INSERT INTO batch_items(id, value) VALUES (?1, ?2)".into(),
12056 parameters: vec![SqlValue::Integer(id), SqlValue::Text(format!("value-{id}"))],
12057 }],
12058 };
12059
12060 let results = runtime
12061 .execute_sql_batch(vec![
12062 insert("same", 1),
12063 insert("same", 2),
12064 insert("other", 3),
12065 ])
12066 .unwrap();
12067
12068 assert!(results[0].is_ok());
12069 assert!(matches!(results[1], Err(NodeError::RequestConflict(_))));
12070 let conflict = results[1].as_ref().unwrap_err().classification();
12071 assert_eq!(conflict.code(), "request_conflict");
12072 assert_eq!(conflict.category(), ErrorCategory::Conflict);
12073 assert!(!conflict.retryable());
12074 assert!(results[2].is_ok());
12075 assert_eq!(
12076 results[0].as_ref().unwrap().applied_index,
12077 results[2].as_ref().unwrap().applied_index
12078 );
12079 assert!(runtime.is_ready());
12080 }
12081
12082 #[cfg(feature = "graph")]
12083 #[test]
12084 fn typed_batch_wrong_profile_is_rejected_before_log_attempt() {
12085 let (_dir, runtime) = graph_test_runtime();
12086 let command = SqlCommand {
12087 request_id: "wrong-profile".into(),
12088 statements: vec![SqlStatement {
12089 sql: "CREATE TABLE should_not_exist(id INTEGER PRIMARY KEY)".into(),
12090 parameters: vec![],
12091 }],
12092 };
12093
12094 let error = runtime.execute_sql_batch(vec![command]).unwrap_err();
12095
12096 assert!(matches!(
12097 error,
12098 NodeError::ExecutionProfileMismatch {
12099 expected: ExecutionProfile::Sqlite,
12100 actual: ExecutionProfile::Graph
12101 }
12102 ));
12103 assert_eq!(runtime.log_store().last_index().unwrap(), None);
12104 }
12105
12106 #[cfg(feature = "graph")]
12107 #[test]
12108 fn graph_query_timeout_returns_503_without_latching_readiness() {
12109 let (_dir, runtime) = graph_test_runtime();
12110 let graph = runtime.graph_materializer().unwrap();
12111 let graph_error = graph
12112 .query_read_only(
12113 "UNWIND range(1, 10000) AS x UNWIND range(1, 10000) AS y RETURN sum(x * y) AS total LIMIT 1",
12114 &std::collections::BTreeMap::new(),
12115 1,
12116 1024 * 1024,
12117 1,
12118 )
12119 .unwrap_err();
12120
12121 let response = node_error_response(runtime.map_graph_read_error(graph_error));
12122
12123 assert_eq!(
12124 response.status(),
12125 axum::http::StatusCode::SERVICE_UNAVAILABLE
12126 );
12127 assert!(runtime.is_ready());
12128 assert!(!runtime.is_fatal());
12129 }
12130
12131 #[cfg(feature = "graph")]
12132 #[test]
12133 fn graph_internal_error_returns_500_and_latches_readiness() {
12134 let (_dir, runtime) = graph_test_runtime();
12135
12136 let error =
12137 runtime.map_graph_read_error(rhiza_graph::Error::Ladybug("connection failed".into()));
12138 let response = node_error_response(error);
12139
12140 assert_eq!(
12141 response.status(),
12142 axum::http::StatusCode::INTERNAL_SERVER_ERROR
12143 );
12144 assert!(!runtime.is_ready());
12145 assert!(runtime.is_fatal());
12146 }
12147
12148 fn sql_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12149 sql_test_runtime_configured(None, None)
12150 }
12151
12152 fn sql_test_runtime_with_profiler(
12153 profiler: Option<SqlWriteProfiler>,
12154 ) -> (tempfile::TempDir, NodeRuntime) {
12155 sql_test_runtime_configured(profiler, None)
12156 }
12157
12158 fn sql_test_runtime_configured(
12159 profiler: Option<SqlWriteProfiler>,
12160 queue_capacity: Option<usize>,
12161 ) -> (tempfile::TempDir, NodeRuntime) {
12162 let dir = tempfile::tempdir().unwrap();
12163 let cluster_id = "node-unit-test";
12164 let mut config = NodeConfig::new_embedded(
12165 cluster_id,
12166 "n1",
12167 dir.path().join("node"),
12168 1,
12169 1,
12170 ["n1", "n2", "n3"],
12171 )
12172 .unwrap()
12173 .with_execution_profile(ExecutionProfile::Sqlite)
12174 .unwrap();
12175 if let Some(profiler) = profiler {
12176 config = config.with_sql_write_profiler(profiler);
12177 }
12178 if let Some(queue_capacity) = queue_capacity {
12179 config = config
12180 .with_sql_group_commit_queue_capacity(queue_capacity)
12181 .unwrap();
12182 }
12183 let consensus = Arc::new(
12184 ThreeNodeConsensus::from_recovered_tip(
12185 "rhiza:sql:node-unit-test",
12186 "n1",
12187 1,
12188 1,
12189 [
12190 dir.path().join("recorders/n1"),
12191 dir.path().join("recorders/n2"),
12192 dir.path().join("recorders/n3"),
12193 ],
12194 1,
12195 LogHash::ZERO,
12196 )
12197 .unwrap(),
12198 );
12199 let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12200 (dir, runtime)
12201 }
12202
12203 #[cfg(feature = "graph")]
12204 fn graph_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12205 let dir = tempfile::tempdir().unwrap();
12206 let cluster_id = "node-unit-test";
12207 let config = NodeConfig::new_embedded(
12208 cluster_id,
12209 "n1",
12210 dir.path().join("node"),
12211 1,
12212 1,
12213 ["n1", "n2", "n3"],
12214 )
12215 .unwrap()
12216 .with_execution_profile(ExecutionProfile::Graph)
12217 .unwrap();
12218 let consensus = Arc::new(
12219 ThreeNodeConsensus::from_recovered_tip(
12220 "rhiza:graph:node-unit-test",
12221 "n1",
12222 1,
12223 1,
12224 [
12225 dir.path().join("recorders/n1"),
12226 dir.path().join("recorders/n2"),
12227 dir.path().join("recorders/n3"),
12228 ],
12229 1,
12230 LogHash::ZERO,
12231 )
12232 .unwrap(),
12233 );
12234 let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12235 (dir, runtime)
12236 }
12237
12238 #[cfg(feature = "kv")]
12239 fn kv_test_runtime() -> (tempfile::TempDir, NodeRuntime) {
12240 let dir = tempfile::tempdir().unwrap();
12241 let cluster_id = "node-unit-test";
12242 let config = NodeConfig::new_embedded(
12243 cluster_id,
12244 "n1",
12245 dir.path().join("node"),
12246 1,
12247 1,
12248 ["n1", "n2", "n3"],
12249 )
12250 .unwrap()
12251 .with_execution_profile(ExecutionProfile::Kv)
12252 .unwrap();
12253 let consensus = Arc::new(
12254 ThreeNodeConsensus::from_recovered_tip(
12255 "rhiza:kv:node-unit-test",
12256 "n1",
12257 1,
12258 1,
12259 [
12260 dir.path().join("recorders/n1"),
12261 dir.path().join("recorders/n2"),
12262 dir.path().join("recorders/n3"),
12263 ],
12264 1,
12265 LogHash::ZERO,
12266 )
12267 .unwrap(),
12268 );
12269 let runtime = NodeRuntime::open(config, consensus, &[]).unwrap();
12270 (dir, runtime)
12271 }
12272}
12273
12274#[cfg(feature = "kv")]
12275fn largest_fitting_kv_batch_prefix(commands: &[KvCommandV1]) -> Option<(usize, Vec<u8>)> {
12276 if commands.len() < 3 {
12277 return None;
12278 }
12279 let mut lower = 2_usize;
12280 let mut upper = commands.len() - 1;
12281 let mut largest = None;
12282 while lower <= upper {
12283 let count = lower + (upper - lower) / 2;
12284 let payload = encode_replicated_kv_batch(&commands[..count])
12285 .expect("the validated KV batch prefix remains valid");
12286 if payload.len() <= MAX_COMMAND_BYTES {
12287 largest = Some((count, payload));
12288 lower = count + 1;
12289 } else {
12290 upper = count - 1;
12291 }
12292 }
12293 largest
12294}
12295
12296#[cfg(feature = "graph")]
12297fn validate_typed_batch_len(len: usize) -> Result<(), NodeError> {
12298 if (1..=MAX_WRITE_BATCH_MEMBERS).contains(&len) {
12299 Ok(())
12300 } else {
12301 Err(NodeError::InvalidRequest(format!(
12302 "write batch must contain 1..={MAX_WRITE_BATCH_MEMBERS} commands"
12303 )))
12304 }
12305}
12306
12307#[cfg(feature = "kv")]
12308fn validate_kv_batch_len(len: usize) -> Result<(), NodeError> {
12309 if (1..=MAX_KV_BATCH_MEMBERS).contains(&len) {
12310 Ok(())
12311 } else {
12312 Err(NodeError::InvalidRequest(format!(
12313 "KV write batch must contain 1..={MAX_KV_BATCH_MEMBERS} commands"
12314 )))
12315 }
12316}
12317
12318#[cfg(feature = "sql")]
12319fn validate_sql_batch_len(len: usize) -> Result<(), NodeError> {
12320 if (1..=MAX_TYPED_SQL_WRITE_BATCH_MEMBERS).contains(&len) {
12321 Ok(())
12322 } else {
12323 Err(NodeError::InvalidRequest(format!(
12324 "SQL write batch must contain 1..={MAX_TYPED_SQL_WRITE_BATCH_MEMBERS} commands"
12325 )))
12326 }
12327}
12328
12329fn validate_command_size(payload: &[u8]) -> Result<(), NodeError> {
12330 if payload.len() <= MAX_COMMAND_BYTES {
12331 Ok(())
12332 } else {
12333 Err(NodeError::InvalidRequest(format!(
12334 "command exceeds {MAX_COMMAND_BYTES} bytes"
12335 )))
12336 }
12337}
12338
12339#[cfg(feature = "sql")]
12340fn canonical_put(request_id: &str, key: &str, value: &str) -> Result<Vec<u8>, NodeError> {
12341 validate_field("request_id", request_id, MAX_REQUEST_ID_BYTES, false)?;
12342 validate_key(key)?;
12343 validate_field("value", value, MAX_VALUE_BYTES, true)?;
12344 let payload = encode_put_request(request_id, key, value)
12345 .map_err(|error| NodeError::InvalidRequest(error.to_string()))?;
12346 validate_command_size(&payload)?;
12347 Ok(payload)
12348}
12349
12350#[cfg(feature = "sql")]
12351fn encode_sql_command_with_index(command: &SqlCommand) -> Result<Vec<u8>, NodeError> {
12352 encode_sql_command(command).map_err(|error| {
12353 let message = error.to_string();
12354 match first_invalid_sql_statement(command, |prefix| encode_sql_command(prefix).is_err()) {
12355 Some(statement_index) => NodeError::InvalidSqlStatement {
12356 statement_index,
12357 message,
12358 },
12359 None => NodeError::InvalidRequest(message),
12360 }
12361 })
12362}
12363
12364#[cfg(feature = "sql")]
12365fn first_invalid_sql_statement(
12366 command: &SqlCommand,
12367 mut invalid: impl FnMut(&SqlCommand) -> bool,
12368) -> Option<usize> {
12369 if command.statements.is_empty() || command.statements.len() > MAX_SQL_STATEMENTS {
12370 return None;
12371 }
12372 (0..command.statements.len()).find(|statement_index| {
12373 let prefix = SqlCommand {
12374 request_id: command.request_id.clone(),
12375 statements: command.statements[..=*statement_index].to_vec(),
12376 };
12377 invalid(&prefix)
12378 })
12379}
12380
12381#[cfg(feature = "sql")]
12382fn validate_key(key: &str) -> Result<(), NodeError> {
12383 validate_field("key", key, MAX_KEY_BYTES, false)
12384}
12385
12386#[cfg(feature = "sql")]
12387fn validate_field(
12388 name: &str,
12389 value: &str,
12390 max_bytes: usize,
12391 allow_empty: bool,
12392) -> Result<(), NodeError> {
12393 if !allow_empty && value.is_empty() {
12394 return Err(NodeError::InvalidRequest(format!(
12395 "{name} must not be empty"
12396 )));
12397 }
12398 if value.len() > max_bytes {
12399 return Err(NodeError::InvalidRequest(format!(
12400 "{name} exceeds {max_bytes} bytes"
12401 )));
12402 }
12403 if value.contains('\t') {
12404 return Err(NodeError::InvalidRequest(format!(
12405 "{name} must not contain a tab"
12406 )));
12407 }
12408 Ok(())
12409}
12410
12411#[cfg(feature = "sql")]
12412fn write_response(outcome: RequestOutcome) -> WriteResponse {
12413 WriteResponse {
12414 applied_index: outcome.original_log_index(),
12415 hash: outcome.original_log_hash(),
12416 }
12417}
12418
12419fn reconcile_local_storage(
12420 config: &NodeConfig,
12421 log_store: &FileLogStore,
12422 materializer: &Materializer,
12423) -> Result<(), NodeError> {
12424 let mut log_state = log_store
12425 .logical_state()
12426 .map_err(|error| NodeError::Storage(error.to_string()))?;
12427 let mut log_last_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
12428 let applied_index = materializer
12429 .applied_index()
12430 .map_err(|error| NodeError::Storage(error.to_string()))?;
12431 let applied_hash = materializer
12432 .applied_hash()
12433 .map_err(|error| NodeError::Storage(error.to_string()))?;
12434 let mut materializer_configuration = materializer
12435 .configuration_state()
12436 .map_err(|error| NodeError::Storage(error.to_string()))?;
12437
12438 if let Some(anchor) = &log_state.anchor {
12439 if anchor.recovery_generation() != config.recovery_generation {
12440 return Err(NodeError::Reconciliation(format!(
12441 "qlog anchor recovery generation {} differs from runtime generation {}",
12442 anchor.recovery_generation(),
12443 config.recovery_generation
12444 )));
12445 }
12446 if applied_index < anchor.compacted().index() {
12447 return Err(NodeError::SnapshotRequired(Box::new(anchor.clone())));
12448 }
12449 }
12450 if applied_index > log_last_index {
12451 let entries: Option<Vec<LogEntry>> = match materializer {
12452 #[cfg(feature = "sql")]
12453 Materializer::Sql(sql) => Some(
12454 sql.embedded_log_entries(log_last_index.saturating_add(1), applied_index)
12455 .map_err(|error| NodeError::Reconciliation(error.to_string()))?,
12456 ),
12457 #[cfg(feature = "kv")]
12458 Materializer::Kv(kv) => Some(
12459 kv.embedded_log_entries(log_last_index.saturating_add(1), applied_index)
12460 .map_err(|error| NodeError::Reconciliation(error.to_string()))?,
12461 ),
12462 #[cfg(feature = "graph")]
12463 Materializer::Graph(_) => None,
12464 #[cfg(not(any(feature = "sql", feature = "graph", feature = "kv")))]
12465 Materializer::Unavailable => None,
12466 };
12467 if let Some(entries) = entries {
12468 log_store
12469 .append_batch(&entries)
12470 .map_err(|error| NodeError::Storage(error.to_string()))?;
12471 log_state = log_store
12472 .logical_state()
12473 .map_err(|error| NodeError::Storage(error.to_string()))?;
12474 log_last_index = log_state.tip.as_ref().map_or(0, |tip| tip.index());
12475 }
12476 }
12477 if applied_index > log_last_index {
12478 return Err(NodeError::Reconciliation(format!(
12479 "{} materializer is ahead at {applied_index}, qlog ends at {log_last_index}",
12480 materializer.profile()
12481 )));
12482 }
12483 if applied_index == 0 {
12484 if applied_hash != LogHash::ZERO {
12485 return Err(NodeError::Reconciliation(format!(
12486 "{} materializer genesis hash is not zero",
12487 materializer.profile()
12488 )));
12489 }
12490 } else if !log_state.anchor.as_ref().is_some_and(|anchor| {
12491 applied_index == anchor.compacted().index() && applied_hash == anchor.compacted().hash()
12492 }) {
12493 let entry = log_store
12494 .read(applied_index)
12495 .map_err(|error| NodeError::Storage(error.to_string()))?
12496 .ok_or_else(|| {
12497 NodeError::Reconciliation(format!(
12498 "qlog prefix is missing {} materializer index {applied_index}",
12499 materializer.profile()
12500 ))
12501 })?;
12502 validate_entry_envelope(config, &entry, applied_index, entry.prev_hash)?;
12503 if entry.hash != applied_hash {
12504 return Err(NodeError::Reconciliation(format!(
12505 "{} materializer hash diverges from qlog at index {applied_index}",
12506 materializer.profile()
12507 )));
12508 }
12509 }
12510
12511 let mut expected_prev_hash = applied_hash;
12512 for index in (applied_index + 1)..=log_last_index {
12513 let entry = log_store
12514 .read(index)
12515 .map_err(|error| NodeError::Storage(error.to_string()))?
12516 .ok_or_else(|| {
12517 NodeError::Reconciliation(format!("qlog prefix is missing index {index}"))
12518 })?;
12519 match &materializer_configuration {
12520 Some(configuration) => {
12521 validate_runtime_entry(config, configuration, &entry, index, expected_prev_hash)?
12522 }
12523 None => validate_entry_envelope(config, &entry, index, expected_prev_hash)?,
12524 };
12525 materializer
12526 .apply_entry(&entry)
12527 .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12528 materializer_configuration = materializer
12529 .configuration_state()
12530 .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12531 expected_prev_hash = entry.hash;
12532 }
12533 let log_configuration = log_store
12534 .configuration_state()
12535 .map_err(|error| NodeError::Storage(error.to_string()))?;
12536 if materializer_configuration
12537 .as_ref()
12538 .is_some_and(|configuration| configuration != &log_configuration)
12539 {
12540 return Err(NodeError::Reconciliation(format!(
12541 "qlog and {} materializer configuration states disagree",
12542 materializer.profile()
12543 )));
12544 }
12545 Ok(())
12546}
12547
12548fn recover_peer_candidates(
12549 config: &NodeConfig,
12550 consensus: &ThreeNodeConsensus,
12551 log_store: &FileLogStore,
12552 materializer: &Materializer,
12553 peer_candidates: &[&dyn LogPeer],
12554) -> Result<(), NodeError> {
12555 for peer in peer_candidates {
12556 let (last_index, last_hash) = static_log_tip(log_store)?;
12557 let candidates = match peer.fetch_log(FetchLogRequest {
12558 from_index: last_index.saturating_add(1),
12559 max_entries: MAX_FETCH_ENTRIES,
12560 }) {
12561 Ok(response) => validate_fetched_entries_with_configuration(
12562 last_index.saturating_add(1),
12563 last_hash,
12564 &config.cluster_id,
12565 config.epoch,
12566 log_store
12567 .configuration_state()
12568 .map_err(|error| NodeError::Storage(error.to_string()))?,
12569 response.entries,
12570 )
12571 .map_err(|error| {
12572 NodeError::Reconciliation(format!("peer candidate validation failed: {error}"))
12573 })?,
12574 Err(FetchLogError::Transport { .. }) => continue,
12575 Err(FetchLogError::SnapshotRequired { anchor }) => {
12576 return Err(NodeError::SnapshotRequired(anchor));
12577 }
12578 Err(error) => {
12579 return Err(NodeError::Reconciliation(format!(
12580 "peer candidate validation failed: {error}"
12581 )));
12582 }
12583 };
12584
12585 let mut expected_index = last_index.checked_add(1).ok_or_else(|| {
12586 NodeError::Reconciliation("qlog index is exhausted during peer catch-up".into())
12587 })?;
12588 let mut expected_prev_hash = last_hash;
12589 for candidate in candidates {
12590 match consensus
12591 .inspect_decision_at(expected_index, expected_prev_hash)
12592 .map_err(startup_consensus_error)?
12593 {
12594 DecisionInspection::Committed(committed) if committed == candidate => {
12595 persist_startup_entry(
12596 config,
12597 log_store,
12598 materializer,
12599 &candidate,
12600 expected_index,
12601 expected_prev_hash,
12602 )?;
12603 expected_prev_hash = candidate.hash;
12604 expected_index = expected_index.checked_add(1).ok_or_else(|| {
12605 NodeError::Reconciliation(
12606 "qlog index is exhausted during peer catch-up".into(),
12607 )
12608 })?;
12609 }
12610 DecisionInspection::Committed(_) => {
12611 return Err(NodeError::Reconciliation(format!(
12612 "peer candidate at index {expected_index} differs from committed decision"
12613 )));
12614 }
12615 DecisionInspection::Unavailable => {
12616 return Err(NodeError::Unavailable(format!(
12617 "decision inspection unavailable for peer candidate at index {expected_index}"
12618 )));
12619 }
12620 DecisionInspection::Empty | DecisionInspection::Pending => {
12621 return Err(NodeError::Reconciliation(format!(
12622 "peer candidate at index {expected_index} is not committed"
12623 )));
12624 }
12625 }
12626 }
12627 }
12628 Ok(())
12629}
12630
12631fn recover_startup_decisions(
12632 config: &NodeConfig,
12633 consensus: &ThreeNodeConsensus,
12634 log_store: &FileLogStore,
12635 materializer: &Materializer,
12636) -> Result<(), NodeError> {
12637 for _ in 0..MAX_STARTUP_RECOVERY_ENTRIES {
12638 let (last_index, last_hash) = static_log_tip(log_store)?;
12639 let slot = last_index.checked_add(1).ok_or_else(|| {
12640 NodeError::Reconciliation("qlog index is exhausted during startup".into())
12641 })?;
12642 match consensus
12643 .inspect_decision_at(slot, last_hash)
12644 .map_err(startup_consensus_error)?
12645 {
12646 DecisionInspection::Committed(entry) => {
12647 persist_startup_entry(config, log_store, materializer, &entry, slot, last_hash)?;
12648 }
12649 DecisionInspection::Pending => {
12650 let entry = consensus
12651 .propose_at(
12652 slot,
12653 last_hash,
12654 Command::new(CommandKind::ReadBarrier, Vec::new()),
12655 )
12656 .map_err(startup_consensus_error)?;
12657 persist_startup_entry(config, log_store, materializer, &entry, slot, last_hash)?;
12658 }
12659 DecisionInspection::Empty => return Ok(()),
12660 DecisionInspection::Unavailable => {
12661 return Err(NodeError::Unavailable(
12662 "decision inspection unavailable during startup".into(),
12663 ));
12664 }
12665 }
12666 }
12667 Err(NodeError::Reconciliation(format!(
12668 "startup recovery exceeded {MAX_STARTUP_RECOVERY_ENTRIES} entries"
12669 )))
12670}
12671
12672fn persist_startup_entry(
12673 config: &NodeConfig,
12674 log_store: &FileLogStore,
12675 materializer: &Materializer,
12676 entry: &LogEntry,
12677 expected_index: LogIndex,
12678 expected_prev_hash: LogHash,
12679) -> Result<(), NodeError> {
12680 let configuration_state = log_store
12681 .configuration_state()
12682 .map_err(|error| NodeError::Storage(error.to_string()))?;
12683 validate_runtime_entry(
12684 config,
12685 &configuration_state,
12686 entry,
12687 expected_index,
12688 expected_prev_hash,
12689 )?;
12690 log_store
12691 .append(entry)
12692 .map_err(|error| NodeError::Storage(error.to_string()))?;
12693 materializer
12694 .apply_entry(entry)
12695 .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12696 Ok(())
12697}
12698
12699fn static_log_tip(log_store: &FileLogStore) -> Result<(LogIndex, LogHash), NodeError> {
12700 Ok(log_store
12701 .logical_state()
12702 .map_err(|error| NodeError::Storage(error.to_string()))?
12703 .tip
12704 .map_or((0, LogHash::ZERO), |tip| (tip.index(), tip.hash())))
12705}
12706
12707fn validate_runtime_entry(
12708 config: &NodeConfig,
12709 configuration_state: &ConfigurationState,
12710 entry: &LogEntry,
12711 expected_index: LogIndex,
12712 expected_prev_hash: LogHash,
12713) -> Result<(), NodeError> {
12714 validate_entry_envelope(config, entry, expected_index, expected_prev_hash)?;
12715 validate_profile_entry_shape(config.execution_profile(), entry)
12716 .map_err(NodeError::Invariant)?;
12717 configuration_state
12718 .validate_entry(entry)
12719 .map_err(|error| NodeError::Reconciliation(error.to_string()))?;
12720 Ok(())
12721}
12722
12723fn validate_entry_envelope(
12724 config: &NodeConfig,
12725 entry: &LogEntry,
12726 expected_index: LogIndex,
12727 expected_prev_hash: LogHash,
12728) -> Result<(), NodeError> {
12729 if entry.index != expected_index {
12730 return Err(NodeError::Reconciliation(format!(
12731 "expected decision index {expected_index}, got {}",
12732 entry.index
12733 )));
12734 }
12735 if entry.cluster_id != config.cluster_id || entry.epoch != config.epoch {
12736 return Err(NodeError::Reconciliation(format!(
12737 "decision {} has a foreign identity",
12738 entry.index
12739 )));
12740 }
12741 if entry.prev_hash != expected_prev_hash {
12742 return Err(NodeError::Reconciliation(format!(
12743 "decision {} has a conflicting predecessor",
12744 entry.index
12745 )));
12746 }
12747 if entry.recompute_hash() != entry.hash {
12748 return Err(NodeError::Reconciliation(format!(
12749 "decision {} has an invalid hash",
12750 entry.index
12751 )));
12752 }
12753 validate_entry_shape(entry).map_err(NodeError::Invariant)
12754}
12755
12756fn validate_entry_shape(entry: &LogEntry) -> Result<(), String> {
12757 match entry.entry_type {
12758 EntryType::Command if entry.payload.len() <= MAX_COMMAND_BYTES => Ok(()),
12759 EntryType::Command => Err(format!("command exceeds {MAX_COMMAND_BYTES} bytes")),
12760 EntryType::Noop if entry.payload.is_empty() => Ok(()),
12761 EntryType::Noop => Err("Noop payload must be empty".into()),
12762 EntryType::ConfigChange => ConfigChange::recognize(&StoredCommand::new(
12763 EntryType::ConfigChange,
12764 entry.payload.clone(),
12765 ))
12766 .map(|_| ())
12767 .map_err(|error| error.to_string()),
12768 other => Err(format!("unsupported runtime entry type {other:?}")),
12769 }
12770}
12771
12772pub(crate) fn validate_profile_entry_shape(
12773 _profile: ExecutionProfile,
12774 entry: &LogEntry,
12775) -> Result<(), String> {
12776 validate_entry_shape(entry)?;
12777 #[cfg(feature = "sql")]
12778 if _profile == ExecutionProfile::Sqlite && entry.entry_type == EntryType::Command {
12779 decode_qwal_v3(&entry.payload)
12780 .map_err(|error| format!("SQLite command is not canonical QWAL v3: {error}"))?;
12781 }
12782 Ok(())
12783}
12784
12785fn startup_consensus_error(error: rhiza_quepaxa::Error) -> NodeError {
12786 match error {
12787 rhiza_quepaxa::Error::NoQuorum
12788 | rhiza_quepaxa::Error::CommandUnavailable
12789 | rhiza_quepaxa::Error::Io(_) => NodeError::Unavailable(error.to_string()),
12790 other => NodeError::Reconciliation(other.to_string()),
12791 }
12792}
12793
12794#[cfg(feature = "sql")]
12795#[derive(Clone, Debug, Eq, PartialEq)]
12796pub struct E2eConfig {
12797 pub data_dir: PathBuf,
12798 pub object_store: ObjStoreConfig,
12799 pub cluster_id: String,
12800 pub node_id: String,
12801}
12802
12803#[cfg(feature = "sql")]
12804#[derive(Clone, Debug, Eq, PartialEq)]
12805pub struct E2eReport {
12806 pub applied_index: LogIndex,
12807 pub restored_value: String,
12808 pub object_keys: Vec<String>,
12809}
12810
12811#[cfg(feature = "sql")]
12812pub async fn run_e2e(config: E2eConfig) -> Result<E2eReport, Box<dyn std::error::Error>> {
12813 let sqlite_dir = config.data_dir.join("sqlite");
12814 let log_dir = config.data_dir.join("consensus").join("log");
12815 ensure_fresh_e2e_data_dir(&config.data_dir, &sqlite_dir, &log_dir)?;
12816
12817 fs::create_dir_all(&config.data_dir)?;
12818 let db_path = sqlite_dir.join("db.sqlite");
12819 let restore_path = config.data_dir.join("restore").join("db.sqlite");
12820 let db = SqliteStateMachine::open(&db_path, &config.cluster_id, &config.node_id, 1, 1)?;
12821 let recorder_dir = config.data_dir.join("consensus").join("recorder");
12822 let consensus = ThreeNodeConsensus::new(
12823 &config.cluster_id,
12824 &config.node_id,
12825 1,
12826 1,
12827 [
12828 recorder_dir.join("node-1"),
12829 recorder_dir.join("node-2"),
12830 recorder_dir.join("node-3"),
12831 ],
12832 )?;
12833 let base_request = canonical_put("e2e-base", "alpha", "bravo")?;
12834 let base_effect = db.prepare_put_effect(
12835 "e2e-base",
12836 "alpha",
12837 "bravo",
12838 &base_request,
12839 0,
12840 LogHash::ZERO,
12841 )?;
12842 let base_entry = consensus.propose(Command::new(CommandKind::Deterministic, base_effect))?;
12843 db.apply_entry(&base_entry)?;
12844 let snapshot = db.create_snapshot(base_entry.index)?;
12845
12846 let tail_request = canonical_put("e2e-tail", "alpha", "charlie")?;
12847 let tail_effect = db.prepare_put_effect(
12848 "e2e-tail",
12849 "alpha",
12850 "charlie",
12851 &tail_request,
12852 base_entry.index,
12853 base_entry.hash,
12854 )?;
12855 let tail_entry = consensus.propose(Command::new(CommandKind::Deterministic, tail_effect))?;
12856 let segment_path = write_segment_file(&log_dir, std::slice::from_ref(&tail_entry))?;
12857 let segment = rhiza_log::SegmentFile::new(
12858 IndexRange::new(tail_entry.index, tail_entry.index)?,
12859 fs::read(&segment_path)?,
12860 );
12861 db.apply_entry(&tail_entry)?;
12862
12863 let local_archive = matches!(&config.object_store, ObjStoreConfig::Local { .. });
12864 let store = ObjStore::new(config.object_store)?;
12865 let archive = if local_archive {
12866 rhiza_archive::ObjectArchiveStore::new_for_single_process(
12867 store.clone(),
12868 config.cluster_id.clone(),
12869 )
12870 } else {
12871 rhiza_archive::ObjectArchiveStore::new(store.clone(), config.cluster_id.clone())?
12872 };
12873
12874 let segment_record = archive.publish_segment(tail_entry.epoch, &segment).await?;
12875 let snapshot_record = archive.publish_snapshot(&snapshot).await?;
12876 let (mut archive_manifest, expected_manifest_version) = match archive.load_manifest().await? {
12877 Some(loaded) => (loaded.manifest().clone(), Some(loaded.version().clone())),
12878 None => (
12879 rhiza_archive::ArchiveManifest::new(config.cluster_id.clone()),
12880 None,
12881 ),
12882 };
12883 archive_manifest.set_latest_snapshot(snapshot_record);
12884 archive_manifest.add_segment(segment_record);
12885 archive
12886 .publish_manifest(&archive_manifest, expected_manifest_version)
12887 .await?;
12888
12889 let loaded_manifest = archive
12890 .load_manifest()
12891 .await?
12892 .ok_or("published archive manifest is missing")?;
12893 if loaded_manifest.manifest() != &archive_manifest {
12894 return Err("reloaded archive manifest did not match the published manifest".into());
12895 }
12896 let archived_snapshot = loaded_manifest
12897 .manifest()
12898 .latest_snapshot()
12899 .ok_or("archive manifest is missing its snapshot")?;
12900 let archived_segment = loaded_manifest
12901 .manifest()
12902 .segments()
12903 .iter()
12904 .find(|record| {
12905 record.start_index() == tail_entry.index && record.end_index() == tail_entry.index
12906 })
12907 .ok_or("archive manifest is missing its post-snapshot segment")?;
12908
12909 let downloaded_segment = archive.download_segment(archived_segment).await?;
12910 let downloaded_entries = decode_segment_for_cluster(&downloaded_segment, &config.cluster_id)?;
12911 if downloaded_entries.as_slice() != std::slice::from_ref(&tail_entry) {
12912 return Err("downloaded qlog segment did not match written entry".into());
12913 }
12914 let downloaded_snapshot = rhiza_core::Snapshot::new(
12915 archived_snapshot.manifest().clone(),
12916 archive.download_snapshot(archived_snapshot).await?,
12917 );
12918 restore_snapshot_file(&restore_path, &downloaded_snapshot, &config.node_id)?;
12919 let restored_db = SqliteStateMachine::open_existing(&restore_path)?;
12920 if restored_db.get_value("alpha")?.as_deref() != Some("bravo") {
12921 return Err("restored base snapshot is missing alpha=bravo".into());
12922 }
12923 for entry in &downloaded_entries {
12924 restored_db.apply_entry(entry)?;
12925 }
12926 let restored_value = restored_db
12927 .get_value("alpha")?
12928 .ok_or("restored SQLite state is missing alpha")?;
12929 let applied_index = restored_db.applied_index_value()?;
12930 if applied_index != tail_entry.index || restored_value != "charlie" {
12931 return Err("restored SQLite state did not include the archived log tail".into());
12932 }
12933 let object_keys = store.list(&format!("rhiza/{}", config.cluster_id)).await?;
12934
12935 Ok(E2eReport {
12936 applied_index,
12937 restored_value,
12938 object_keys,
12939 })
12940}
12941
12942#[cfg(feature = "sql")]
12943fn ensure_fresh_e2e_data_dir(
12944 data_dir: &std::path::Path,
12945 sqlite_dir: &std::path::Path,
12946 log_dir: &std::path::Path,
12947) -> Result<(), Box<dyn std::error::Error>> {
12948 if directory_has_entries(sqlite_dir)? || directory_has_entries(log_dir)? {
12949 return Err(format!(
12950 "e2e data directory is not fresh: prior SQLite/qlog data exists in {}",
12951 data_dir.display()
12952 )
12953 .into());
12954 }
12955 Ok(())
12956}
12957
12958#[cfg(feature = "sql")]
12959fn directory_has_entries(path: &std::path::Path) -> Result<bool, std::io::Error> {
12960 match fs::read_dir(path) {
12961 Ok(mut entries) => entries.next().transpose().map(|entry| entry.is_some()),
12962 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
12963 Err(error) => Err(error),
12964 }
12965}