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