1use std::{
2 fmt,
3 io::{self, Write},
4 path::{Path, PathBuf},
5 sync::{
6 atomic::{AtomicBool, Ordering},
7 Arc, Mutex,
8 },
9 time::{Duration, Instant},
10};
11
12use axum::{
13 extract::{Json, State},
14 http::{HeaderMap, StatusCode},
15 response::{IntoResponse, Response},
16 routing::post,
17 Router,
18};
19use rhiza_core::{
20 ConfigChange, ConfigurationState, ExecutionProfile, LogAnchor, LogEntry, LogHash, StoredCommand,
21};
22use rhiza_log::{FileLogStore, LogStore};
23use rhiza_quepaxa::{CertifiedDecisionInspection, DecisionProof, Membership, ThreeNodeConsensus};
24use serde::{Deserialize, Serialize};
25
26use crate::{
27 durability::{
28 adopt_finalized_successor_prestage, finalize_successor_prestage_for_stop,
29 inspect_successor_prestage, SuccessorPrestage, SuccessorPrestageState,
30 SuccessorRestorePreparation,
31 },
32 header_text, secrets_equal, valid_auth_token, valid_nonblank_header_value,
33 validate_profile_entry_shape, Materializer, NodeConfig, NodeRuntime, StopInformation,
34 RECOVERY_GENERATION_HEADER,
35};
36
37pub const CERTIFIED_TAIL_PATH: &str = "/v1/learner/certified-tail";
38pub const TAIL_PROTOCOL_VERSION: &str = "1";
39pub const TAIL_VERSION_HEADER: &str = "x-rhiza-tail-version";
40pub const TAIL_CLUSTER_ID_HEADER: &str = "x-rhiza-tail-cluster-id";
41pub const TAIL_EPOCH_HEADER: &str = "x-rhiza-tail-epoch";
42pub const TAIL_CONFIG_ID_HEADER: &str = "x-rhiza-tail-config-id";
43pub const TAIL_MEMBERSHIP_DIGEST_HEADER: &str = "x-rhiza-tail-membership-digest";
44pub const MAX_CERTIFIED_TAIL_ENTRIES: u32 = 1_024;
45pub const DEFAULT_CERTIFIED_TAIL_ENTRIES: u32 = 64;
46pub const MAX_CERTIFIED_TAIL_ENCODED_BYTES: usize = 8 * 1024 * 1024;
47pub const MAX_CERTIFIED_TAIL_ENTRY_PAYLOAD_BYTES: usize = 1024 * 1024;
48pub const MAX_CONCURRENT_CERTIFIED_TAIL_READS: usize = 2;
49pub const CERTIFIED_TAIL_READ_TIMEOUT: Duration = Duration::from_secs(10);
50const TAIL_AUTHORIZATION_SCHEME: &str = "Rhiza-Tail ";
51
52#[derive(Clone, Eq, PartialEq)]
53pub struct TailReaderConfig {
54 cluster_id: String,
55 epoch: u64,
56 config_id: u64,
57 membership: Membership,
58 recovery_generation: u64,
59 token: String,
60}
61
62impl TailReaderConfig {
63 pub fn new(
64 cluster_id: impl Into<String>,
65 epoch: u64,
66 config_id: u64,
67 membership: Membership,
68 recovery_generation: u64,
69 token: impl Into<String>,
70 ) -> Result<Self, TailReaderConfigError> {
71 let cluster_id = cluster_id.into();
72 if !valid_nonblank_header_value(&cluster_id) {
73 return Err(TailReaderConfigError::InvalidClusterId);
74 }
75 if epoch == 0 {
76 return Err(TailReaderConfigError::InvalidEpoch);
77 }
78 if config_id == 0 {
79 return Err(TailReaderConfigError::InvalidConfigId);
80 }
81 if recovery_generation == 0 {
82 return Err(TailReaderConfigError::InvalidRecoveryGeneration);
83 }
84 let token = token.into();
85 if !valid_auth_token(&token) {
86 return Err(TailReaderConfigError::InvalidToken);
87 }
88 Ok(Self {
89 cluster_id,
90 epoch,
91 config_id,
92 membership,
93 recovery_generation,
94 token,
95 })
96 }
97
98 pub fn cluster_id(&self) -> &str {
99 &self.cluster_id
100 }
101
102 pub const fn epoch(&self) -> u64 {
103 self.epoch
104 }
105
106 pub const fn config_id(&self) -> u64 {
107 self.config_id
108 }
109
110 pub const fn membership(&self) -> &Membership {
111 &self.membership
112 }
113
114 pub const fn membership_digest(&self) -> LogHash {
115 self.membership.digest()
116 }
117
118 pub const fn recovery_generation(&self) -> u64 {
119 self.recovery_generation
120 }
121}
122
123impl fmt::Debug for TailReaderConfig {
124 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
125 formatter
126 .debug_struct("TailReaderConfig")
127 .field("cluster_id", &self.cluster_id)
128 .field("epoch", &self.epoch)
129 .field("config_id", &self.config_id)
130 .field("membership_digest", &self.membership.digest())
131 .field("recovery_generation", &self.recovery_generation)
132 .field("token", &"[redacted]")
133 .finish()
134 }
135}
136
137#[derive(Clone, Debug, Eq, PartialEq)]
138pub enum TailReaderConfigError {
139 InvalidClusterId,
140 InvalidEpoch,
141 InvalidConfigId,
142 InvalidRecoveryGeneration,
143 InvalidToken,
144 ConsensusContextMismatch,
145}
146
147impl fmt::Display for TailReaderConfigError {
148 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149 let message = match self {
150 Self::InvalidClusterId => "tail reader cluster_id must be a nonblank HTTP header value",
151 Self::InvalidEpoch => "tail reader epoch must be positive",
152 Self::InvalidConfigId => "tail reader config_id must be positive",
153 Self::InvalidRecoveryGeneration => "tail reader recovery_generation must be positive",
154 Self::InvalidToken => "tail reader token must be nonblank and contain no whitespace",
155 Self::ConsensusContextMismatch => {
156 "tail reader membership and config_id must match consensus"
157 }
158 };
159 formatter.write_str(message)
160 }
161}
162
163impl std::error::Error for TailReaderConfigError {}
164
165#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
166#[serde(deny_unknown_fields)]
167pub struct CertifiedTailRequest {
168 pub from: LogAnchor,
169 pub max_entries: u32,
170}
171
172impl CertifiedTailRequest {
173 pub const fn from_anchor(from: LogAnchor) -> Self {
174 Self {
175 from,
176 max_entries: DEFAULT_CERTIFIED_TAIL_ENTRIES,
177 }
178 }
179}
180
181#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
182pub struct CertifiedTailRecord {
183 pub entry: LogEntry,
184 pub proof: DecisionProof,
185}
186
187#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
188pub struct CertifiedTailResponse {
189 pub records: Vec<CertifiedTailRecord>,
190 pub observed_tip: LogAnchor,
191}
192
193#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
194#[serde(tag = "error", rename_all = "snake_case", deny_unknown_fields)]
195pub enum CertifiedTailErrorResponse {
196 RebaseRequired { checkpoint: LogAnchor },
197}
198
199#[derive(Clone)]
200struct TailReaderState {
201 consensus: Arc<ThreeNodeConsensus>,
202 log: TailLogSource,
203 config: TailReaderConfig,
204 admission: Arc<tokio::sync::Semaphore>,
205}
206
207#[derive(Clone)]
208enum TailLogSource {
209 Shared(Arc<FileLogStore>),
210 Runtime(Arc<NodeRuntime>),
211}
212
213impl TailLogSource {
214 fn store(&self) -> &FileLogStore {
215 match self {
216 Self::Shared(log) => log,
217 Self::Runtime(runtime) => runtime.log_store(),
218 }
219 }
220}
221
222pub fn certified_tail_router(
223 consensus: Arc<ThreeNodeConsensus>,
224 log: Arc<FileLogStore>,
225 config: TailReaderConfig,
226) -> Result<Router, TailReaderConfigError> {
227 build_certified_tail_router(consensus, TailLogSource::Shared(log), config)
228}
229
230pub fn certified_tail_router_for_runtime(
231 runtime: Arc<NodeRuntime>,
232 config: TailReaderConfig,
233) -> Result<Router, TailReaderConfigError> {
234 let consensus = Arc::clone(runtime.consensus());
235 build_certified_tail_router(consensus, TailLogSource::Runtime(runtime), config)
236}
237
238fn build_certified_tail_router(
239 consensus: Arc<ThreeNodeConsensus>,
240 log: TailLogSource,
241 config: TailReaderConfig,
242) -> Result<Router, TailReaderConfigError> {
243 if consensus.config_id() != config.config_id || consensus.membership() != &config.membership {
244 return Err(TailReaderConfigError::ConsensusContextMismatch);
245 }
246 Ok(Router::new()
247 .route(CERTIFIED_TAIL_PATH, post(handle_certified_tail))
248 .with_state(TailReaderState {
249 consensus,
250 log,
251 config,
252 admission: Arc::new(tokio::sync::Semaphore::new(
253 MAX_CONCURRENT_CERTIFIED_TAIL_READS,
254 )),
255 }))
256}
257
258async fn handle_certified_tail(
259 State(state): State<TailReaderState>,
260 headers: HeaderMap,
261 request: Result<Json<CertifiedTailRequest>, axum::extract::rejection::JsonRejection>,
262) -> Response {
263 if !tail_reader_authenticated(&headers, &state.config) {
264 return StatusCode::UNAUTHORIZED.into_response();
265 }
266 let Ok(Json(request)) = request else {
267 return StatusCode::BAD_REQUEST.into_response();
268 };
269 if request.max_entries == 0
270 || request.max_entries > MAX_CERTIFIED_TAIL_ENTRIES
271 || (request.from.index() == 0 && request.from.hash() != LogHash::ZERO)
272 {
273 return StatusCode::BAD_REQUEST.into_response();
274 }
275 let permit = match state.admission.clone().try_acquire_owned() {
276 Ok(permit) => permit,
277 Err(_) => return StatusCode::TOO_MANY_REQUESTS.into_response(),
278 };
279 let cancellation = TailReadCancellation::new();
280 let cancelled = cancellation.signal();
281 let deadline = Instant::now() + CERTIFIED_TAIL_READ_TIMEOUT;
282 let permit = Arc::new(permit);
283 let worker_permit = Arc::clone(&permit);
284 let task = tokio::task::spawn_blocking(move || {
285 let _permit = worker_permit;
286 read_certified_tail(&state, request, &cancelled, deadline)
287 });
288 let _permit = permit;
289 match tokio::time::timeout(CERTIFIED_TAIL_READ_TIMEOUT, task).await {
290 Ok(Ok(Ok(response))) => Json(response).into_response(),
291 Ok(Ok(Err(TailReadError::DecisionMismatch))) => StatusCode::CONFLICT.into_response(),
292 Ok(Ok(Err(TailReadError::ResponseTooLarge))) => {
293 StatusCode::PAYLOAD_TOO_LARGE.into_response()
294 }
295 Ok(Ok(Err(TailReadError::RebaseRequired(checkpoint)))) => (
296 StatusCode::CONFLICT,
297 Json(CertifiedTailErrorResponse::RebaseRequired { checkpoint }),
298 )
299 .into_response(),
300 Ok(Ok(Err(TailReadError::Unavailable))) | Err(_) => {
301 StatusCode::SERVICE_UNAVAILABLE.into_response()
302 }
303 Ok(Err(_)) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
304 }
305}
306
307fn read_certified_tail(
308 state: &TailReaderState,
309 request: CertifiedTailRequest,
310 cancelled: &AtomicBool,
311 deadline: Instant,
312) -> Result<CertifiedTailResponse, TailReadError> {
313 ensure_tail_read_active(cancelled, deadline)?;
314 let logical = state
315 .log
316 .store()
317 .logical_state()
318 .map_err(|_| TailReadError::Unavailable)?;
319 if let Some(checkpoint) = logical.anchor.as_ref().map(|anchor| *anchor.compacted()) {
320 if request.from.index() < checkpoint.index() {
321 return Err(TailReadError::RebaseRequired(checkpoint));
322 }
323 }
324 let observed_tip = logical
325 .tip
326 .unwrap_or_else(|| LogAnchor::new(0, LogHash::ZERO));
327 if request.from.index() > observed_tip.index()
328 || (request.from.index() == observed_tip.index()
329 && request.from.hash() != observed_tip.hash())
330 {
331 return Err(TailReadError::DecisionMismatch);
332 }
333 let available = observed_tip.index().saturating_sub(request.from.index());
334 let count = available.min(u64::from(request.max_entries));
335 let mut records = Vec::with_capacity(count as usize);
336 let mut encoded_bytes = encoded_json_len(&CertifiedTailResponse {
337 records: Vec::new(),
338 observed_tip,
339 })
340 .map_err(|_| TailReadError::Unavailable)?;
341 let mut previous = request.from;
342 for offset in 1..=count {
343 ensure_tail_read_active(cancelled, deadline)?;
344 let index = request
345 .from
346 .index()
347 .checked_add(offset)
348 .ok_or(TailReadError::Unavailable)?;
349 let Some(local) = state
350 .log
351 .store()
352 .read(index)
353 .map_err(|_| TailReadError::Unavailable)?
354 else {
355 break;
356 };
357 if local.payload.len() > MAX_CERTIFIED_TAIL_ENTRY_PAYLOAD_BYTES {
358 return Err(TailReadError::ResponseTooLarge);
359 }
360 if local.cluster_id != state.config.cluster_id
361 || local.epoch != state.config.epoch
362 || local.config_id != state.config.config_id
363 || local.prev_hash != previous.hash()
364 {
365 return Err(TailReadError::DecisionMismatch);
366 }
367 let inspection = state
368 .consensus
369 .inspect_certified_decision_at(index, local.prev_hash)
370 .map_err(|_| TailReadError::Unavailable)?;
371 let CertifiedDecisionInspection::Committed(certified) = inspection else {
372 break;
373 };
374 ensure_tail_read_active(cancelled, deadline)?;
375 certified
376 .proof
377 .validate_for_cluster(
378 &state.config.cluster_id,
379 index,
380 state.config.epoch,
381 state.config.config_id,
382 &state.config.membership,
383 )
384 .map_err(|_| TailReadError::DecisionMismatch)?;
385 if certified.entry != local {
386 return Err(TailReadError::DecisionMismatch);
387 }
388 let record = CertifiedTailRecord {
389 entry: local,
390 proof: certified.proof,
391 };
392 let record_bytes = encoded_json_len(&record).map_err(|_| TailReadError::Unavailable)?;
393 let next_encoded_bytes = encoded_bytes
394 .checked_add(record_bytes)
395 .and_then(|bytes| bytes.checked_add(usize::from(!records.is_empty())))
396 .ok_or(TailReadError::ResponseTooLarge)?;
397 if next_encoded_bytes > MAX_CERTIFIED_TAIL_ENCODED_BYTES {
398 if records.is_empty() {
399 return Err(TailReadError::ResponseTooLarge);
400 }
401 break;
402 }
403 encoded_bytes = next_encoded_bytes;
404 let local = &record.entry;
405 previous = LogAnchor::new(local.index, local.hash);
406 records.push(record);
407 }
408 Ok(CertifiedTailResponse {
409 records,
410 observed_tip,
411 })
412}
413
414fn tail_reader_authenticated(headers: &HeaderMap, config: &TailReaderConfig) -> bool {
415 let Some(token) = header_text(headers, "authorization")
416 .and_then(|value| value.strip_prefix(TAIL_AUTHORIZATION_SCHEME))
417 else {
418 return false;
419 };
420 header_text(headers, TAIL_VERSION_HEADER) == Some(TAIL_PROTOCOL_VERSION)
421 && header_text(headers, TAIL_CLUSTER_ID_HEADER) == Some(config.cluster_id.as_str())
422 && header_text(headers, TAIL_EPOCH_HEADER) == Some(config.epoch.to_string().as_str())
423 && header_text(headers, TAIL_CONFIG_ID_HEADER)
424 == Some(config.config_id.to_string().as_str())
425 && header_text(headers, TAIL_MEMBERSHIP_DIGEST_HEADER)
426 == Some(config.membership.digest().to_hex().as_str())
427 && header_text(headers, RECOVERY_GENERATION_HEADER)
428 == Some(config.recovery_generation.to_string().as_str())
429 && secrets_equal(token.as_bytes(), config.token.as_bytes())
430}
431
432enum TailReadError {
433 DecisionMismatch,
434 RebaseRequired(LogAnchor),
435 ResponseTooLarge,
436 Unavailable,
437}
438
439struct TailReadCancellation(Arc<AtomicBool>);
440
441impl TailReadCancellation {
442 fn new() -> Self {
443 Self(Arc::new(AtomicBool::new(false)))
444 }
445
446 fn signal(&self) -> Arc<AtomicBool> {
447 Arc::clone(&self.0)
448 }
449}
450
451impl Drop for TailReadCancellation {
452 fn drop(&mut self) {
453 self.0.store(true, Ordering::Release);
454 }
455}
456
457fn ensure_tail_read_active(cancelled: &AtomicBool, deadline: Instant) -> Result<(), TailReadError> {
458 if cancelled.load(Ordering::Acquire) || Instant::now() >= deadline {
459 Err(TailReadError::Unavailable)
460 } else {
461 Ok(())
462 }
463}
464
465#[derive(Default)]
466struct EncodedByteCounter(usize);
467
468impl Write for EncodedByteCounter {
469 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
470 self.0 = self.0.saturating_add(bytes.len());
471 Ok(bytes.len())
472 }
473
474 fn flush(&mut self) -> io::Result<()> {
475 Ok(())
476 }
477}
478
479fn encoded_json_len(value: &impl Serialize) -> Result<usize, serde_json::Error> {
480 let mut counter = EncodedByteCounter::default();
481 serde_json::to_writer(&mut counter, value)?;
482 Ok(counter.0)
483}
484
485#[derive(Clone, Debug, Eq, PartialEq)]
486pub enum CertifiedTailValidationError {
487 InvalidRequest,
488 TooManyRecords,
489 EncodedPageTooLarge,
490 EntryTooLarge,
491 RegressedObservedTip,
492 InvalidChain,
493 ForeignContext,
494 InvalidEntryHash,
495 InvalidProof,
496 ProofEntryMismatch,
497}
498
499impl fmt::Display for CertifiedTailValidationError {
500 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
501 let message = match self {
502 Self::InvalidRequest => "certified tail request is invalid",
503 Self::TooManyRecords => "certified tail response exceeds the requested bound",
504 Self::EncodedPageTooLarge => "certified tail response exceeds the encoded byte bound",
505 Self::EntryTooLarge => "certified tail response contains an oversized entry",
506 Self::RegressedObservedTip => {
507 "certified tail observed tip regresses the request anchor"
508 }
509 Self::InvalidChain => "certified tail records are not consecutive from the request",
510 Self::ForeignContext => "certified tail record has a foreign cluster or configuration",
511 Self::InvalidEntryHash => "certified tail record has an invalid entry hash",
512 Self::InvalidProof => "certified tail record has an invalid decision proof",
513 Self::ProofEntryMismatch => {
514 "certified tail decision proof does not identify the returned entry"
515 }
516 };
517 formatter.write_str(message)
518 }
519}
520
521impl std::error::Error for CertifiedTailValidationError {}
522
523pub fn validate_certified_tail_response(
524 config: &TailReaderConfig,
525 request: &CertifiedTailRequest,
526 response: &CertifiedTailResponse,
527) -> Result<(), CertifiedTailValidationError> {
528 if request.max_entries == 0
529 || request.max_entries > MAX_CERTIFIED_TAIL_ENTRIES
530 || (request.from.index() == 0 && request.from.hash() != LogHash::ZERO)
531 {
532 return Err(CertifiedTailValidationError::InvalidRequest);
533 }
534 if response.records.len() > request.max_entries as usize {
535 return Err(CertifiedTailValidationError::TooManyRecords);
536 }
537 if response
538 .records
539 .iter()
540 .any(|record| record.entry.payload.len() > MAX_CERTIFIED_TAIL_ENTRY_PAYLOAD_BYTES)
541 {
542 return Err(CertifiedTailValidationError::EntryTooLarge);
543 }
544 if encoded_json_len(response).unwrap_or(usize::MAX) > MAX_CERTIFIED_TAIL_ENCODED_BYTES {
545 return Err(CertifiedTailValidationError::EncodedPageTooLarge);
546 }
547 if response.observed_tip.index() < request.from.index()
548 || (response.observed_tip.index() == request.from.index()
549 && response.observed_tip.hash() != request.from.hash())
550 {
551 return Err(CertifiedTailValidationError::RegressedObservedTip);
552 }
553
554 let mut previous = request.from;
555 for record in &response.records {
556 let entry = &record.entry;
557 let Some(expected_index) = previous.index().checked_add(1) else {
558 return Err(CertifiedTailValidationError::InvalidChain);
559 };
560 if entry.index != expected_index
561 || entry.prev_hash != previous.hash()
562 || entry.index > response.observed_tip.index()
563 {
564 return Err(CertifiedTailValidationError::InvalidChain);
565 }
566 if entry.cluster_id != config.cluster_id
567 || entry.epoch != config.epoch
568 || entry.config_id != config.config_id
569 {
570 return Err(CertifiedTailValidationError::ForeignContext);
571 }
572 if entry.recompute_hash() != entry.hash {
573 return Err(CertifiedTailValidationError::InvalidEntryHash);
574 }
575 record
576 .proof
577 .validate_for_cluster(
578 &config.cluster_id,
579 entry.index,
580 config.epoch,
581 config.config_id,
582 &config.membership,
583 )
584 .map_err(|_| CertifiedTailValidationError::InvalidProof)?;
585 let Some(value) = record.proof.proposal().value.as_ref() else {
586 return Err(CertifiedTailValidationError::ProofEntryMismatch);
587 };
588 let command_hash = StoredCommand::new(entry.entry_type, entry.payload.clone()).hash();
589 if value.command_hash != command_hash
590 || value.prev_hash != entry.prev_hash
591 || value.entry_hash != entry.hash
592 {
593 return Err(CertifiedTailValidationError::ProofEntryMismatch);
594 }
595 previous = LogAnchor::new(entry.index, entry.hash);
596 }
597 if previous.index() == response.observed_tip.index()
598 && previous.hash() != response.observed_tip.hash()
599 {
600 return Err(CertifiedTailValidationError::InvalidChain);
601 }
602 Ok(())
603}
604
605pub struct LearnerStore {
606 prestage: SuccessorPrestage,
607 config: TailReaderConfig,
608 log: FileLogStore,
609 materializer: Materializer,
610 apply_lock: Mutex<()>,
611}
612
613#[derive(Clone, Copy, Debug, Eq, PartialEq)]
614pub struct LearnerProgress {
615 pub durable: LogAnchor,
616 pub applied: LogAnchor,
617 pub observed_source_tip: LogAnchor,
618}
619
620#[derive(Clone, Debug, Eq, PartialEq)]
621pub enum LearnerStoreError {
622 PrestageNotPublished,
623 ContextMismatch,
624 MissingMaterializer(PathBuf),
625 Storage(String),
626 InvalidPage(CertifiedTailValidationError),
627 ReanchorRequired(LogAnchor),
628 AnchorMismatch,
629 InvalidStop,
630 Poisoned,
631}
632
633impl fmt::Display for LearnerStoreError {
634 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
635 match self {
636 Self::PrestageNotPublished => {
637 formatter.write_str("learner requires a published successor prestage")
638 }
639 Self::ContextMismatch => {
640 formatter.write_str("learner context does not match successor prestage")
641 }
642 Self::MissingMaterializer(path) => write!(
643 formatter,
644 "published successor prestage is missing materializer {}",
645 path.display()
646 ),
647 Self::Storage(message) => write!(formatter, "learner storage failed: {message}"),
648 Self::InvalidPage(error) => write!(formatter, "learner page is invalid: {error}"),
649 Self::ReanchorRequired(anchor) => write!(
650 formatter,
651 "learner request must restart from durable anchor {}/{}",
652 anchor.index(),
653 anchor.hash().to_hex()
654 ),
655 Self::AnchorMismatch => {
656 formatter.write_str("learner durable and applied anchors do not match")
657 }
658 Self::InvalidStop => {
659 formatter.write_str("learner finalization requires the exact bound Stop")
660 }
661 Self::Poisoned => formatter.write_str("learner apply lock is poisoned"),
662 }
663 }
664}
665
666impl std::error::Error for LearnerStoreError {}
667
668impl LearnerStore {
669 pub fn open(
670 data_dir: impl Into<PathBuf>,
671 config: TailReaderConfig,
672 ) -> Result<Self, LearnerStoreError> {
673 let data_dir = data_dir.into();
674 let prestage = inspect_successor_prestage(
675 &data_dir,
676 ConfigurationState::active(config.config_id, config.membership.digest()),
677 )
678 .map_err(|error| LearnerStoreError::Storage(error.to_string()))?;
679 if prestage.state() != SuccessorPrestageState::Published {
680 return Err(LearnerStoreError::PrestageNotPublished);
681 }
682 let identity = prestage.identity();
683 let target_config_id = config
684 .config_id
685 .checked_add(1)
686 .ok_or(LearnerStoreError::ContextMismatch)?;
687 if identity.cluster_id() != config.cluster_id
688 || identity.epoch() != config.epoch
689 || identity.predecessor_config_id() != config.config_id
690 || identity.predecessor_recovery_generation() != config.recovery_generation
691 || identity.target_config_id() != target_config_id
692 {
693 return Err(LearnerStoreError::ContextMismatch);
694 }
695 let materializer_path = detached_materializer_path(&data_dir, identity.execution_profile());
696 if !materializer_path.is_file() {
697 return Err(LearnerStoreError::MissingMaterializer(materializer_path));
698 }
699 let initial_configuration =
700 ConfigurationState::active(config.config_id, config.membership.digest());
701 let log = FileLogStore::open_with_configuration(
702 data_dir.join("consensus/log"),
703 &config.cluster_id,
704 config.epoch,
705 initial_configuration,
706 )
707 .map_err(|error| LearnerStoreError::Storage(error.to_string()))?;
708 let persisted_configuration = log
709 .configuration_state()
710 .map_err(|error| LearnerStoreError::Storage(error.to_string()))?;
711 let materializer = Materializer::open_detached(
712 &data_dir,
713 identity.execution_profile(),
714 &config.cluster_id,
715 identity.node_id(),
716 config.epoch,
717 &persisted_configuration,
718 )
719 .map_err(|error| LearnerStoreError::Storage(error.to_string()))?;
720 let store = Self {
721 prestage,
722 config,
723 log,
724 materializer,
725 apply_lock: Mutex::new(()),
726 };
727 store.reconcile()?;
728 store.validate_seed()?;
729 Ok(store)
730 }
731
732 pub fn durable_anchor(&self) -> Result<LogAnchor, LearnerStoreError> {
733 self.log
734 .logical_state()
735 .map_err(|error| LearnerStoreError::Storage(error.to_string()))
736 .map(|state| state.tip.unwrap_or_else(genesis_anchor))
737 }
738
739 pub fn applied_anchor(&self) -> Result<LogAnchor, LearnerStoreError> {
740 self.materializer
741 .applied_tip()
742 .map_err(LearnerStoreError::Storage)
743 }
744
745 pub fn tail_request(
746 &self,
747 max_entries: u32,
748 ) -> Result<CertifiedTailRequest, LearnerStoreError> {
749 if max_entries == 0 || max_entries > MAX_CERTIFIED_TAIL_ENTRIES {
750 return Err(LearnerStoreError::InvalidPage(
751 CertifiedTailValidationError::InvalidRequest,
752 ));
753 }
754 let _guard = self
755 .apply_lock
756 .lock()
757 .map_err(|_| LearnerStoreError::Poisoned)?;
758 self.reconcile()?;
759 let durable = self.durable_anchor()?;
760 if self.applied_anchor()? != durable {
761 return Err(LearnerStoreError::AnchorMismatch);
762 }
763 Ok(CertifiedTailRequest {
764 from: durable,
765 max_entries,
766 })
767 }
768
769 pub fn apply_page(
770 &self,
771 request: &CertifiedTailRequest,
772 response: &CertifiedTailResponse,
773 ) -> Result<LearnerProgress, LearnerStoreError> {
774 let _guard = self
775 .apply_lock
776 .lock()
777 .map_err(|_| LearnerStoreError::Poisoned)?;
778 validate_certified_tail_response(&self.config, request, response)
779 .map_err(LearnerStoreError::InvalidPage)?;
780 self.reconcile()?;
781 let durable = self.durable_anchor()?;
782 let applied = self.applied_anchor()?;
783 if durable != applied {
784 return Err(LearnerStoreError::AnchorMismatch);
785 }
786 if request.from != durable {
787 return Err(LearnerStoreError::ReanchorRequired(durable));
788 }
789 let mut configuration = self
790 .log
791 .configuration_state()
792 .map_err(|error| LearnerStoreError::Storage(error.to_string()))?;
793 let mut entries = Vec::with_capacity(response.records.len());
794 for record in &response.records {
795 configuration = validate_detached_entry(
796 self.prestage.identity().execution_profile(),
797 &self.config,
798 &configuration,
799 &record.entry,
800 )?;
801 entries.push(record.entry.clone());
802 }
803 if !entries.is_empty() {
804 self.log
805 .append_batch_buffered(&entries)
806 .map_err(|error| LearnerStoreError::Storage(error.to_string()))?;
807 self.log
808 .sync()
809 .map_err(|error| LearnerStoreError::Storage(error.to_string()))?;
810 }
811 for entry in &entries {
812 self.materializer
813 .apply_entry(entry)
814 .map_err(LearnerStoreError::Storage)?;
815 }
816 let durable = self.durable_anchor()?;
817 let applied = self.applied_anchor()?;
818 if durable != applied {
819 return Err(LearnerStoreError::AnchorMismatch);
820 }
821 Ok(LearnerProgress {
822 durable,
823 applied,
824 observed_source_tip: response.observed_tip,
825 })
826 }
827
828 pub fn finalize(
829 self,
830 successor_config: &NodeConfig,
831 stop: &StopInformation,
832 ) -> Result<SuccessorRestorePreparation, LearnerStoreError> {
833 self.validate_stop(successor_config, stop)?;
834 let anchor = LogAnchor::new(stop.entry.index, stop.entry.hash);
835 if self.durable_anchor()? != anchor || self.applied_anchor()? != anchor {
836 return Err(LearnerStoreError::AnchorMismatch);
837 }
838 let predecessor_membership = self.config.membership.clone();
839 let Self {
840 prestage,
841 log,
842 materializer,
843 ..
844 } = self;
845 materializer
846 .close_for_handoff()
847 .map_err(LearnerStoreError::Storage)?;
848 drop(log);
849 let finalized =
850 finalize_successor_prestage_for_stop(prestage, stop, &predecessor_membership)
851 .map_err(|error| LearnerStoreError::Storage(error.to_string()))?;
852 adopt_finalized_successor_prestage(
853 finalized,
854 successor_config,
855 stop,
856 &predecessor_membership,
857 )
858 .map_err(|error| LearnerStoreError::Storage(error.to_string()))
859 }
860
861 fn reconcile(&self) -> Result<(), LearnerStoreError> {
862 let durable = self.durable_anchor()?;
863 let mut applied = self.applied_anchor()?;
864 if applied.index() > durable.index()
865 || (applied.index() == durable.index() && applied.hash() != durable.hash())
866 || !self.log_contains(applied)?
867 {
868 return Err(LearnerStoreError::AnchorMismatch);
869 }
870 let mut configuration =
871 ConfigurationState::active(self.config.config_id, self.config.membership.digest());
872 while applied.index() < durable.index() {
873 let index = applied
874 .index()
875 .checked_add(1)
876 .ok_or(LearnerStoreError::AnchorMismatch)?;
877 let entry = self
878 .log
879 .read(index)
880 .map_err(|error| LearnerStoreError::Storage(error.to_string()))?
881 .ok_or(LearnerStoreError::AnchorMismatch)?;
882 configuration = validate_detached_entry(
883 self.prestage.identity().execution_profile(),
884 &self.config,
885 &configuration,
886 &entry,
887 )?;
888 self.materializer
889 .apply_entry(&entry)
890 .map_err(LearnerStoreError::Storage)?;
891 applied = LogAnchor::new(entry.index, entry.hash);
892 }
893 if self.applied_anchor()? != durable {
894 return Err(LearnerStoreError::AnchorMismatch);
895 }
896 Ok(())
897 }
898
899 fn validate_seed(&self) -> Result<(), LearnerStoreError> {
900 let seed = self.prestage.identity().seed_anchor();
901 if self.durable_anchor()?.index() < seed.index()
902 || self.applied_anchor()?.index() < seed.index()
903 || !self.log_contains(seed)?
904 {
905 return Err(LearnerStoreError::AnchorMismatch);
906 }
907 Ok(())
908 }
909
910 fn log_contains(&self, anchor: LogAnchor) -> Result<bool, LearnerStoreError> {
911 if anchor == genesis_anchor() {
912 return Ok(true);
913 }
914 let state = self
915 .log
916 .logical_state()
917 .map_err(|error| LearnerStoreError::Storage(error.to_string()))?;
918 if state
919 .anchor
920 .as_ref()
921 .is_some_and(|recovery| *recovery.compacted() == anchor)
922 {
923 return Ok(true);
924 }
925 self.log
926 .read(anchor.index())
927 .map_err(|error| LearnerStoreError::Storage(error.to_string()))
928 .map(|entry| entry.is_some_and(|entry| entry.hash == anchor.hash()))
929 }
930
931 fn validate_stop(
932 &self,
933 successor_config: &NodeConfig,
934 stop: &StopInformation,
935 ) -> Result<(), LearnerStoreError> {
936 let identity = self.prestage.identity();
937 if stop.entry.cluster_id != self.config.cluster_id
938 || stop.entry.epoch != self.config.epoch
939 || stop.entry.config_id != self.config.config_id
940 || stop.entry.recompute_hash() != stop.entry.hash
941 {
942 return Err(LearnerStoreError::InvalidStop);
943 }
944 let change = ConfigChange::recognize_parts(stop.entry.entry_type, &stop.entry.payload)
945 .map_err(|_| LearnerStoreError::InvalidStop)?;
946 let ConfigChange::BoundStop { successor } = change else {
947 return Err(LearnerStoreError::InvalidStop);
948 };
949 if successor.cluster_id() != self.config.cluster_id
950 || successor.predecessor_config_id() != self.config.config_id
951 || successor.predecessor_config_digest() != self.config.membership.digest()
952 || successor.config_id() != identity.target_config_id()
953 || successor.digest() != identity.target_membership_digest()
954 || successor_config.cluster_id() != successor.cluster_id()
955 || successor_config.epoch() != self.config.epoch
956 || successor_config.config_id().checked_add(1) != Some(successor.config_id())
957 || successor_config.membership().digest() != successor.digest()
958 || successor_config.data_dir() != self.prestage.path()
959 {
960 return Err(LearnerStoreError::InvalidStop);
961 }
962 stop.proof
963 .validate_for_cluster(
964 &self.config.cluster_id,
965 stop.entry.index,
966 self.config.epoch,
967 self.config.config_id,
968 &self.config.membership,
969 )
970 .map_err(|_| LearnerStoreError::InvalidStop)?;
971 if !proof_matches_entry(&stop.proof, &stop.entry) {
972 return Err(LearnerStoreError::InvalidStop);
973 }
974 let configuration = self
975 .log
976 .configuration_state()
977 .map_err(|error| LearnerStoreError::Storage(error.to_string()))?;
978 if configuration.stop() != Some(&LogAnchor::new(stop.entry.index, stop.entry.hash)) {
979 return Err(LearnerStoreError::InvalidStop);
980 }
981 Ok(())
982 }
983}
984
985fn validate_detached_entry(
986 profile: ExecutionProfile,
987 config: &TailReaderConfig,
988 configuration: &ConfigurationState,
989 entry: &LogEntry,
990) -> Result<ConfigurationState, LearnerStoreError> {
991 if entry.cluster_id != config.cluster_id
992 || entry.epoch != config.epoch
993 || entry.config_id != config.config_id
994 || entry.recompute_hash() != entry.hash
995 {
996 return Err(LearnerStoreError::ContextMismatch);
997 }
998 validate_profile_entry_shape(profile, entry).map_err(LearnerStoreError::Storage)?;
999 configuration
1000 .validate_entry(entry)
1001 .map_err(|error| LearnerStoreError::Storage(error.to_string()))
1002}
1003
1004fn detached_materializer_path(data_dir: &Path, profile: ExecutionProfile) -> PathBuf {
1005 match profile {
1006 ExecutionProfile::Sqlite => data_dir.join("sqlite/db.sqlite"),
1007 ExecutionProfile::Graph => data_dir.join("ladybug/graph.lbug"),
1008 ExecutionProfile::Kv => data_dir.join("kv/data.redb"),
1009 }
1010}
1011
1012const fn genesis_anchor() -> LogAnchor {
1013 LogAnchor::new(0, LogHash::ZERO)
1014}
1015
1016fn proof_matches_entry(proof: &DecisionProof, entry: &LogEntry) -> bool {
1017 proof.proposal().value.as_ref().is_some_and(|value| {
1018 value.command_hash == StoredCommand::new(entry.entry_type, entry.payload.clone()).hash()
1019 && value.prev_hash == entry.prev_hash
1020 && value.entry_hash == entry.hash
1021 })
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use super::*;
1027
1028 #[test]
1029 fn tail_read_admission_rejects_work_past_the_fixed_capacity() {
1030 let admission = Arc::new(tokio::sync::Semaphore::new(
1031 MAX_CONCURRENT_CERTIFIED_TAIL_READS,
1032 ));
1033 let permits = (0..MAX_CONCURRENT_CERTIFIED_TAIL_READS)
1034 .map(|_| admission.clone().try_acquire_owned().unwrap())
1035 .collect::<Vec<_>>();
1036
1037 assert!(admission.clone().try_acquire_owned().is_err());
1038 drop(permits);
1039 assert!(admission.try_acquire_owned().is_ok());
1040 }
1041
1042 #[test]
1043 fn tail_read_stops_after_deadline_or_request_cancellation() {
1044 let cancellation = TailReadCancellation::new();
1045 let signal = cancellation.signal();
1046 assert!(matches!(
1047 ensure_tail_read_active(&signal, Instant::now()),
1048 Err(TailReadError::Unavailable)
1049 ));
1050
1051 drop(cancellation);
1052 assert!(matches!(
1053 ensure_tail_read_active(&signal, Instant::now() + Duration::from_secs(1)),
1054 Err(TailReadError::Unavailable)
1055 ));
1056 }
1057}