Skip to main content

rhiza_node/
learner.rs

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}