Skip to main content

hydracache_server/
bootstrap.rs

1use hydracache::{ClusterGridCounters, HydraCache};
2use hydracache_client_transport_axum::{ClientSurfaceDrain, ClientSurfaceRuntime};
3use hydracache_observability::{
4    ClusterMemberView, ClusterOverview, ClusterTopologyOverview, ConsistencyView,
5    HydraCacheRegistry, LeaderView, LifecycleView, PartitionSummary, TopologyReshardPhase,
6    TopologyStatusSource,
7};
8use hydracache_redis_compat::{RedisListenerConfig, RedisRespServer, RedisServeError};
9use serde::Serialize;
10use std::net::SocketAddr;
11use std::sync::Arc;
12use std::time::Instant;
13use thiserror::Error;
14
15use crate::cluster_status::{
16    ClusterStatus, ClusterStatusProvider, ClusterStatusRuntime, GridControlPlaneHandle,
17    LiveClusterStatus, MemberRole, ModeledClusterStatus, RaftCompactionError, RaftCompactionStatus,
18    Reachability, ReshardPhase, StatusSource,
19};
20use crate::config::{ServerConfig, ServerConfigError, ServerRole};
21use crate::redis_tcp::{RedisTlsAcceptor, RedisTlsError};
22use crate::services::{DrainOutcome, GracefulShutdown, ServiceSet};
23
24/// Runtime state exposed by health/readiness checks.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ServerState {
28    /// Runtime has not started.
29    Created,
30    /// Runtime accepts requests.
31    Running,
32    /// Runtime is draining in-flight work.
33    Draining,
34    /// Runtime stopped cleanly.
35    Stopped,
36}
37
38/// Liveness response.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
40pub struct ServerHealth {
41    /// Stable status field for probes.
42    pub status: &'static str,
43    /// Current runtime state.
44    pub state: ServerState,
45}
46
47/// Readiness response.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
49pub struct ServerReadiness {
50    /// Whether the daemon can serve traffic.
51    pub ready: bool,
52    /// Whether durable storage has opened.
53    pub storage_open: bool,
54    /// Whether the configured cluster role is ready.
55    pub cluster_ready: bool,
56    /// Whether listeners are accepting new work.
57    pub accepting: bool,
58    /// Whether the external client surface is accepting work.
59    pub client_surface_ready: bool,
60    /// Whether the optional Redis RESP surface is accepting work.
61    pub redis_surface_ready: bool,
62}
63
64/// Admin status consumed by the Kubernetes operator.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
66pub struct ServerAdminStatus {
67    /// Whether this status is live or modeled.
68    pub source: StatusSource,
69    /// Current leader id if known to the runtime.
70    pub leader: Option<String>,
71    /// Current control-plane term if known.
72    pub term: u64,
73    /// Current committed membership authority epoch.
74    pub epoch: u64,
75    /// Whether the runtime believes quorum is available.
76    pub quorum_ok: bool,
77    /// Observed member count.
78    pub members: u32,
79    /// Sorted logical member ids in the committed metadata view.
80    pub member_ids: Vec<String>,
81    /// Observed raft voter count.
82    pub voters: u32,
83    /// Sorted numeric ids in the committed Raft voter set.
84    pub voter_ids: Vec<u64>,
85    /// Current reshard phase.
86    pub reshard_phase: String,
87    /// Whether the runtime is draining.
88    pub draining: bool,
89}
90
91/// Accepted admin action response.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
93pub struct ServerAdminAction {
94    /// Stable action name.
95    pub action: &'static str,
96    /// Stable outcome string.
97    pub outcome: &'static str,
98    /// Human-readable detail, safe for operator Conditions.
99    pub detail: String,
100}
101
102/// Modeled Redis RESP listener runtime.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct RedisSurfaceRuntime {
105    accepting: bool,
106    active_connections: u64,
107}
108
109impl RedisSurfaceRuntime {
110    fn new() -> Self {
111        Self {
112            accepting: false,
113            active_connections: 0,
114        }
115    }
116
117    fn start(&mut self) {
118        self.accepting = true;
119    }
120
121    fn accepting(&self) -> bool {
122        self.accepting
123    }
124
125    fn begin_connection(&mut self) -> bool {
126        if !self.accepting {
127            return false;
128        }
129        self.active_connections = self.active_connections.saturating_add(1);
130        true
131    }
132
133    fn finish_connection(&mut self) {
134        self.active_connections = self.active_connections.saturating_sub(1);
135    }
136
137    fn active_connections(&self) -> u64 {
138        self.active_connections
139    }
140
141    fn shutdown(&mut self) -> RedisSurfaceDrain {
142        self.accepting = false;
143        let started_with = self.active_connections;
144        self.active_connections = 0;
145        RedisSurfaceDrain {
146            started_with,
147            remaining: self.active_connections,
148        }
149    }
150}
151
152/// Result of draining modeled Redis RESP connections.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub struct RedisSurfaceDrain {
155    /// Active RESP connections observed when drain started.
156    pub started_with: u64,
157    /// Active RESP connections after drain.
158    pub remaining: u64,
159}
160
161/// Additional read-only observability signals supplied by the daemon host.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct ServerObservabilityModel {
164    cluster_grid: ClusterGridCounters,
165    partition_count: u64,
166    configured_default_consistency: Option<String>,
167    backup_age_seconds: Option<u64>,
168    upgrade_phase: String,
169}
170
171impl ServerObservabilityModel {
172    /// Attach aggregate grid counters supplied by the hosting runtime.
173    pub fn with_cluster_grid_counters(mut self, counters: ClusterGridCounters) -> Self {
174        self.cluster_grid = counters;
175        self
176    }
177
178    /// Attach the effective partition count.
179    pub fn with_partition_count(mut self, count: u64) -> Self {
180        self.partition_count = count;
181        self
182    }
183
184    /// Attach the configured default consistency label.
185    pub fn with_configured_default_consistency(mut self, level: impl Into<String>) -> Self {
186        self.configured_default_consistency = Some(level.into());
187        self
188    }
189
190    /// Attach the worst known backup age.
191    pub fn with_backup_age_seconds(mut self, seconds: u64) -> Self {
192        self.backup_age_seconds = Some(seconds);
193        self
194    }
195
196    /// Attach backup ages for namespaces, keeping the oldest/worst age.
197    pub fn with_backup_age_seconds_from_namespaces(
198        mut self,
199        ages: impl IntoIterator<Item = u64>,
200    ) -> Self {
201        self.backup_age_seconds = ages.into_iter().max();
202        self
203    }
204
205    /// Attach the current graceful-upgrade phase.
206    pub fn with_upgrade_phase(mut self, phase: impl Into<String>) -> Self {
207        self.upgrade_phase = phase.into();
208        self
209    }
210}
211
212impl Default for ServerObservabilityModel {
213    fn default() -> Self {
214        Self {
215            cluster_grid: ClusterGridCounters::default(),
216            partition_count: 0,
217            configured_default_consistency: None,
218            backup_age_seconds: None,
219            upgrade_phase: "idle".to_owned(),
220        }
221    }
222}
223
224/// Fail-loud admin action errors.
225#[derive(Debug, Clone, PartialEq, Eq, Error)]
226pub enum ServerAdminActionError {
227    /// The runtime is not ready to accept the requested action.
228    #[error("server is not ready for admin action: {0}")]
229    NotReady(&'static str),
230    /// The action requires member mode in the current server model.
231    #[error("{0} requires member mode")]
232    RequiresMember(&'static str),
233    /// Backup cannot run without configured backup support.
234    #[error("backup admin action requires backup.enabled and backup.location")]
235    BackupDisabled,
236    /// The live grid rejected a Raft compaction request.
237    #[error(transparent)]
238    RaftCompaction(#[from] RaftCompactionError),
239}
240
241/// Standalone server runtime.
242#[derive(Debug, Clone)]
243pub struct ServerRuntime {
244    config: ServerConfig,
245    cache: HydraCache,
246    services: ServiceSet,
247    state: ServerState,
248    storage_open: bool,
249    cluster_ready: bool,
250    accepting: bool,
251    flushed: bool,
252    client_surface: Option<ClientSurfaceRuntime>,
253    client_dispatch_state: Option<Arc<hydracache_client_transport_axum::ClientSurfaceState>>,
254    redis_listener_config: Option<RedisListenerConfig>,
255    redis_surface: Option<RedisSurfaceRuntime>,
256    cluster_status: Arc<dyn ClusterStatusProvider>,
257    grid_control: Option<Arc<dyn GridControlPlaneHandle>>,
258    observability: ServerObservabilityModel,
259    last_client_surface_drain: Option<ClientSurfaceDrain>,
260    last_redis_surface_drain: Option<RedisSurfaceDrain>,
261    last_drain: Option<DrainOutcome>,
262}
263
264impl ServerRuntime {
265    /// Validate config and construct a runtime.
266    pub fn new(config: ServerConfig) -> Result<Self, ServerConfigError> {
267        config.validate()?;
268        let (cache, cluster_status, grid_control): (
269            HydraCache,
270            Arc<dyn ClusterStatusProvider>,
271            Option<Arc<dyn GridControlPlaneHandle>>,
272        ) = match config.role {
273            ServerRole::Member => {
274                let (cache, grid) = crate::grid_host::build_member(&config)?;
275                (
276                    cache,
277                    Arc::new(LiveClusterStatus::new(Arc::clone(&grid))),
278                    Some(grid),
279                )
280            }
281            ServerRole::Local | ServerRole::Client => (
282                HydraCache::local().build(),
283                Arc::new(ModeledClusterStatus),
284                None,
285            ),
286        };
287        let client_surface = if config.client_api.enabled {
288            Some(
289                ClientSurfaceRuntime::new(config.client_api.limits)
290                    .map_err(|error| ServerConfigError::InvalidClientApi(error.to_string()))?,
291            )
292        } else {
293            None
294        };
295        let client_dispatch_state = if config.redis_api.enabled || config.hc2_client_plane.enabled {
296            Some(match &client_surface {
297                Some(surface) => surface.state(),
298                None => Arc::new(
299                    hydracache_client_transport_axum::ClientSurfaceState::new(
300                        config.client_api.limits,
301                    )
302                    .map_err(|error| ServerConfigError::InvalidClientApi(error.to_string()))?,
303                ),
304            })
305        } else {
306            None
307        };
308        let redis_surface = if config.redis_api.enabled {
309            Some(RedisSurfaceRuntime::new())
310        } else {
311            None
312        };
313        let redis_listener_config = if config.redis_api.enabled {
314            Some(config.redis_listener_config()?)
315        } else {
316            None
317        };
318        Ok(Self {
319            config,
320            cache,
321            services: ServiceSet::default(),
322            state: ServerState::Created,
323            storage_open: false,
324            cluster_ready: false,
325            accepting: false,
326            flushed: false,
327            client_surface,
328            client_dispatch_state,
329            redis_listener_config,
330            redis_surface,
331            cluster_status,
332            grid_control,
333            observability: ServerObservabilityModel::default(),
334            last_client_surface_drain: None,
335            last_redis_surface_drain: None,
336            last_drain: None,
337        })
338    }
339
340    /// Override the cluster-status provider.
341    ///
342    /// This is the W0 seam used by tests and later by the member-role grid host.
343    pub fn with_cluster_status_provider(
344        mut self,
345        cluster_status: Arc<dyn ClusterStatusProvider>,
346    ) -> Self {
347        self.cluster_status = cluster_status;
348        self
349    }
350
351    /// Override additional read-only observability signals.
352    pub fn with_observability_model(mut self, observability: ServerObservabilityModel) -> Self {
353        self.observability = observability;
354        self
355    }
356
357    /// Start storage, cluster membership, listeners, and background services.
358    pub fn start(mut self) -> Self {
359        self.storage_open = true;
360        self.cluster_ready = matches!(
361            self.config.role,
362            ServerRole::Local | ServerRole::Member | ServerRole::Client
363        );
364        self.accepting = true;
365        if let Some(surface) = self.client_surface.as_mut() {
366            surface.start();
367        }
368        if let Some(surface) = self.redis_surface.as_mut() {
369            surface.start();
370        }
371        self.services.start();
372        self.state = ServerState::Running;
373        self
374    }
375
376    /// Return liveness.
377    pub fn health(&self) -> ServerHealth {
378        ServerHealth {
379            status: if self.state == ServerState::Stopped {
380                "stopped"
381            } else {
382                "ok"
383            },
384            state: self.state,
385        }
386    }
387
388    /// Return readiness.
389    pub fn ready(&self) -> ServerReadiness {
390        ServerReadiness {
391            ready: self.can_serve(),
392            storage_open: self.storage_open,
393            cluster_ready: self.cluster_ready,
394            accepting: self.accepting,
395            client_surface_ready: self.client_surface_ready(),
396            redis_surface_ready: self.redis_surface_ready(),
397        }
398    }
399
400    /// Return whether the runtime can serve traffic.
401    pub fn can_serve(&self) -> bool {
402        self.state == ServerState::Running
403            && self.storage_open
404            && self.cluster_ready
405            && self.accepting
406    }
407
408    /// Return whether the runtime is currently draining.
409    pub fn is_draining(&self) -> bool {
410        self.state == ServerState::Draining
411    }
412
413    /// Begin one in-flight request.
414    pub fn begin_request(&mut self) -> bool {
415        if !self.accepting {
416            return false;
417        }
418        self.services.begin_request();
419        true
420    }
421
422    /// Complete one in-flight request.
423    pub fn finish_request(&mut self) {
424        self.services.finish_request();
425    }
426
427    /// Return whether the external client surface is accepting work.
428    pub fn client_surface_ready(&self) -> bool {
429        self.client_surface
430            .as_ref()
431            .is_some_and(ClientSurfaceRuntime::accepting)
432    }
433
434    /// Begin a modeled client subscription stream.
435    pub fn begin_client_subscription(&self) -> bool {
436        self.client_surface
437            .as_ref()
438            .is_some_and(|surface| surface.begin_subscription().is_ok())
439    }
440
441    /// Return active modeled client subscription streams.
442    pub fn client_active_subscriptions(&self) -> u64 {
443        self.client_surface
444            .as_ref()
445            .map_or(0, |surface| surface.state().active_subscriptions())
446    }
447
448    /// Return the last client-surface drain result, if the surface is enabled.
449    pub fn client_surface_drain(&self) -> Option<ClientSurfaceDrain> {
450        self.last_client_surface_drain
451    }
452
453    /// Return whether the optional Redis RESP surface is accepting work.
454    pub fn redis_surface_ready(&self) -> bool {
455        self.redis_surface
456            .as_ref()
457            .is_some_and(RedisSurfaceRuntime::accepting)
458    }
459
460    /// Begin a modeled RESP connection.
461    pub fn begin_redis_connection(&mut self) -> bool {
462        self.redis_surface
463            .as_mut()
464            .is_some_and(RedisSurfaceRuntime::begin_connection)
465    }
466
467    /// Finish a modeled RESP connection.
468    pub fn finish_redis_connection(&mut self) {
469        if let Some(surface) = self.redis_surface.as_mut() {
470            surface.finish_connection();
471        }
472    }
473
474    /// Return active modeled RESP connections.
475    pub fn redis_active_connections(&self) -> u64 {
476        self.redis_surface
477            .as_ref()
478            .map_or(0, RedisSurfaceRuntime::active_connections)
479    }
480
481    /// Return the last Redis RESP surface drain result, if the surface is enabled.
482    pub fn redis_surface_drain(&self) -> Option<RedisSurfaceDrain> {
483        self.last_redis_surface_drain
484    }
485
486    /// Return the Redis RESP bind address when that optional listener is enabled.
487    pub fn redis_listener_addr(&self) -> Option<SocketAddr> {
488        self.redis_surface
489            .as_ref()
490            .map(|_| self.config.redis_api.listen_addr)
491    }
492
493    /// Build a Redis RESP executor using this runtime's shared client-surface state.
494    pub fn redis_resp_server(&self) -> Result<Option<RedisRespServer>, RedisServeError> {
495        let Some(state) = &self.client_dispatch_state else {
496            return Ok(None);
497        };
498        let Some(config) = &self.redis_listener_config else {
499            return Ok(None);
500        };
501        RedisRespServer::new(Arc::clone(state), config.clone())
502            .map(|server| server.with_native_cache_events(self.cache.clone()))
503            .map(Some)
504    }
505
506    /// Build the optional Redis TLS acceptor when rediss:// is enabled.
507    pub fn redis_tls_acceptor(&self) -> Result<Option<RedisTlsAcceptor>, RedisTlsError> {
508        if !self.config.redis_api.enabled || !self.config.redis_api.rediss_enabled {
509            return Ok(None);
510        }
511        RedisTlsAcceptor::from_tls_config(&self.config.tls).map(Some)
512    }
513
514    /// Return the shared verified-dispatch state used by HC/1, RESP, and HC/2.
515    pub fn client_dispatch_state(
516        &self,
517    ) -> Option<Arc<hydracache_client_transport_axum::ClientSurfaceState>> {
518        self.client_surface
519            .as_ref()
520            .map(ClientSurfaceRuntime::state)
521            .or_else(|| self.client_dispatch_state.as_ref().map(Arc::clone))
522    }
523
524    /// Return whether the local-only destructive diagnostic reset is enabled.
525    pub fn diagnostic_reset_enabled(&self) -> bool {
526        self.config.admin_api.diagnostic_reset_enabled
527    }
528
529    /// Clone the owners cleared by the internal diagnostic reset route.
530    pub fn diagnostic_reset_targets(
531        &self,
532    ) -> (
533        HydraCache,
534        Option<Arc<hydracache_client_transport_axum::ClientSurfaceState>>,
535    ) {
536        (self.cache.clone(), self.client_dispatch_state())
537    }
538
539    /// Stop accepting new work and enter the draining state.
540    pub fn begin_drain(&mut self) {
541        self.begin_local_drain();
542        self.cluster_status.begin_drain();
543    }
544
545    /// Accept an operator/admin drain request without stopping the daemon process.
546    pub fn request_admin_drain(&mut self) -> DrainOutcome {
547        if self.state == ServerState::Stopped {
548            return self.last_drain.unwrap_or(DrainOutcome {
549                started_with: 0,
550                remaining: 0,
551                timed_out: false,
552            });
553        }
554        self.begin_local_drain();
555        let drain_timeout = self.config.drain_timeout();
556        let started = Instant::now();
557        let control_plane_drained = self.prepare_cluster_drain(drain_timeout);
558        let mut outcome = GracefulShutdown::new(drain_timeout.saturating_sub(started.elapsed()))
559            .drain(&mut self.services);
560        outcome.timed_out |= !control_plane_drained;
561        self.last_drain = Some(outcome);
562        outcome
563    }
564
565    fn begin_local_drain(&mut self) {
566        if matches!(self.state, ServerState::Stopped) {
567            return;
568        }
569        self.accepting = false;
570        self.state = ServerState::Draining;
571        if let Some(surface) = self.client_surface.as_mut() {
572            if self
573                .last_client_surface_drain
574                .is_none_or(|drain| drain.remaining > 0)
575            {
576                self.last_client_surface_drain = Some(surface.shutdown());
577            }
578        }
579        if let Some(surface) = self.redis_surface.as_mut() {
580            if self
581                .last_redis_surface_drain
582                .is_none_or(|drain| drain.remaining > 0)
583            {
584                self.last_redis_surface_drain = Some(surface.shutdown());
585            }
586        }
587    }
588
589    /// Gracefully stop accepting, drain, flush, and stop services.
590    pub fn graceful_shutdown(&mut self) -> DrainOutcome {
591        if self.state == ServerState::Stopped {
592            return self.last_drain.unwrap_or(DrainOutcome {
593                started_with: 0,
594                remaining: 0,
595                timed_out: false,
596            });
597        }
598        self.begin_local_drain();
599        let drain_timeout = self.config.drain_timeout();
600        let started = Instant::now();
601        let control_plane_drained = self.prepare_cluster_drain(drain_timeout);
602        let mut outcome = GracefulShutdown::new(drain_timeout.saturating_sub(started.elapsed()))
603            .drain(&mut self.services);
604        outcome.timed_out |= !control_plane_drained;
605        self.flushed = true;
606        self.storage_open = false;
607        self.cluster_ready = false;
608        self.services.stop();
609        self.state = ServerState::Stopped;
610        self.last_drain = Some(outcome);
611        outcome
612    }
613
614    /// Backward-compatible alias for graceful shutdown.
615    pub fn shutdown(&mut self) -> DrainOutcome {
616        self.graceful_shutdown()
617    }
618
619    fn leave_cluster_for_shutdown(&self) {
620        if matches!(self.config.role, ServerRole::Member | ServerRole::Client) {
621            let _ = block_on_cluster_leave(&self.cache);
622        }
623    }
624
625    fn prepare_cluster_drain(&self, timeout: std::time::Duration) -> bool {
626        // NodeLeft must commit while this process is still a voter. Only then
627        // may the voter-removal transition start. Waiting for that removal to
628        // apply keeps a two-voter cluster live until its survivor owns a
629        // single-voter configuration; surviving leaders also reconcile the
630        // same committed membership if this bounded wait fails.
631        self.leave_cluster_for_shutdown();
632        self.cluster_status.begin_drain();
633        self.cluster_status.wait_for_drain_ready(timeout)
634    }
635
636    /// Return admin/operator status derived from the runtime model.
637    pub fn admin_status(&self) -> ServerAdminStatus {
638        let status = self.cluster_status_snapshot();
639        let mut member_ids = status
640            .members
641            .iter()
642            .map(|member| member.node_id.clone())
643            .collect::<Vec<_>>();
644        member_ids.sort();
645        ServerAdminStatus {
646            source: status.source,
647            leader: status.leader,
648            term: status.term,
649            epoch: status.epoch,
650            quorum_ok: status.quorum_ok,
651            members: status.members.len() as u32,
652            member_ids,
653            voters: status.voters,
654            voter_ids: status.voter_ids,
655            reshard_phase: status.reshard_phase.to_string(),
656            draining: status.draining,
657        }
658    }
659
660    /// Return current disk-backed Raft compaction progress without mutating it.
661    pub fn raft_compaction_status(&self) -> Result<RaftCompactionStatus, RaftCompactionError> {
662        self.grid_control.as_ref().map_or_else(
663            || Ok(RaftCompactionStatus::unavailable()),
664            |grid| grid.raft_compaction_status(),
665        )
666    }
667
668    /// Explicitly compact the durable Raft log at current applied progress.
669    pub fn request_raft_compaction(&self) -> Result<RaftCompactionStatus, ServerAdminActionError> {
670        if !self.can_serve() {
671            return Err(ServerAdminActionError::NotReady("raft compaction"));
672        }
673        if !matches!(self.config.role, ServerRole::Member) {
674            return Err(ServerAdminActionError::RequiresMember("raft compaction"));
675        }
676        self.grid_control
677            .as_ref()
678            .ok_or(RaftCompactionError::Unavailable)?
679            .compact_raft_log_at_applied()
680            .map_err(ServerAdminActionError::from)
681    }
682
683    /// Build a metrics registry snapshot for the admin surface.
684    pub fn metrics_registry(&self) -> HydraCacheRegistry {
685        let status = self.cluster_status_snapshot();
686        let registry = HydraCacheRegistry::new()
687            .with_cache("server", self.cache.clone())
688            .with_cluster_grid_counters(self.observability.cluster_grid)
689            .with_topology(ClusterTopologyOverview::new(
690                topology_status_source(status.source),
691                status.members.len() as u64,
692                status.leader,
693                status.epoch,
694                topology_reshard_phase(status.reshard_phase),
695            ));
696        if let Some(seconds) = self.observability.backup_age_seconds {
697            registry.with_backup_age_seconds(seconds)
698        } else {
699            registry
700        }
701    }
702
703    /// Build a read-only Management Center cluster overview.
704    pub fn cluster_overview(&self) -> ClusterOverview {
705        let status = self.cluster_status_snapshot();
706        let counters = overview_cluster_grid_counters(
707            self.cache.cluster_grid_counters(),
708            self.observability.cluster_grid,
709        );
710        ClusterOverview::new(
711            topology_status_source(status.source),
712            status
713                .members
714                .iter()
715                .map(|member| {
716                    ClusterMemberView::new(
717                        member.node_id.clone(),
718                        member_role_label(member.role),
719                        member.reachable == Reachability::Reachable,
720                        reachability_label(member.reachable),
721                        member.generation,
722                    )
723                })
724                .collect(),
725            cluster_overview_leader(&status),
726            PartitionSummary::from_grid_counters(counters, self.observability.partition_count),
727            ConsistencyView::from_grid_counters(
728                self.observability.configured_default_consistency.clone(),
729                counters,
730            ),
731            self.observability.backup_age_seconds,
732            LifecycleView::new(
733                status.reshard_phase.to_string(),
734                self.observability.upgrade_phase.clone(),
735            ),
736        )
737    }
738
739    fn cluster_status_snapshot(&self) -> ClusterStatus {
740        let cluster_ready = self.cluster_ready && self.state != ServerState::Stopped;
741        self.cluster_status
742            .cluster_status(ClusterStatusRuntime::new(cluster_ready, self.is_draining()))
743    }
744
745    /// Request an online reshard through the current runtime model.
746    pub fn request_reshard(&self) -> Result<ServerAdminAction, ServerAdminActionError> {
747        if !self.can_serve() {
748            return Err(ServerAdminActionError::NotReady("reshard"));
749        }
750        if !matches!(self.config.role, ServerRole::Member) {
751            return Err(ServerAdminActionError::RequiresMember("reshard"));
752        }
753        Ok(ServerAdminAction {
754            action: "reshard",
755            outcome: "accepted",
756            detail: "reshard request accepted by member runtime".to_owned(),
757        })
758    }
759
760    /// Request a backup through the current runtime model.
761    pub fn request_backup(&self) -> Result<ServerAdminAction, ServerAdminActionError> {
762        if !self.can_serve() {
763            return Err(ServerAdminActionError::NotReady("backup"));
764        }
765        if !self.config.backup.enabled
766            || self
767                .config
768                .backup
769                .location
770                .as_deref()
771                .unwrap_or("")
772                .trim()
773                .is_empty()
774        {
775            return Err(ServerAdminActionError::BackupDisabled);
776        }
777        Ok(ServerAdminAction {
778            action: "backup",
779            outcome: "accepted",
780            detail: "backup request accepted by configured runtime".to_owned(),
781        })
782    }
783
784    /// Return whether shutdown flushed durable state.
785    pub fn flushed(&self) -> bool {
786        self.flushed
787    }
788
789    /// Return cache handle used by embedded tests/adapters.
790    pub fn cache(&self) -> &HydraCache {
791        &self.cache
792    }
793
794    /// Return the runtime config.
795    pub fn config(&self) -> &ServerConfig {
796        &self.config
797    }
798}
799
800fn topology_status_source(source: StatusSource) -> TopologyStatusSource {
801    match source {
802        StatusSource::Live => TopologyStatusSource::Live,
803        StatusSource::Modeled => TopologyStatusSource::Modeled,
804    }
805}
806
807fn block_on_cluster_leave(cache: &HydraCache) -> hydracache::CacheResult<()> {
808    let cache = cache.clone();
809    if tokio::runtime::Handle::try_current().is_ok() {
810        return std::thread::spawn(move || block_on_cluster_leave_without_current(cache))
811            .join()
812            .map_err(|_| {
813                hydracache::CacheError::Backend("cluster leave helper thread panicked".to_owned())
814            })?;
815    }
816
817    block_on_cluster_leave_without_current(cache)
818}
819
820fn block_on_cluster_leave_without_current(cache: HydraCache) -> hydracache::CacheResult<()> {
821    let runtime = tokio::runtime::Builder::new_current_thread()
822        .enable_all()
823        .build()
824        .map_err(|error| {
825            hydracache::CacheError::Backend(format!(
826                "failed to build cluster leave runtime: {error}"
827            ))
828        })?;
829    let left = runtime.block_on(cache.leave_cluster())?;
830    let _ = left;
831    Ok(())
832}
833
834fn topology_reshard_phase(phase: ReshardPhase) -> TopologyReshardPhase {
835    match phase {
836        ReshardPhase::Idle => TopologyReshardPhase::Idle,
837        ReshardPhase::Planning => TopologyReshardPhase::Planning,
838        ReshardPhase::Moving => TopologyReshardPhase::Moving,
839        ReshardPhase::Finalizing => TopologyReshardPhase::Finalizing,
840    }
841}
842
843fn cluster_overview_leader(status: &ClusterStatus) -> Option<LeaderView> {
844    if status.source != StatusSource::Live || !status.quorum_ok {
845        return None;
846    }
847    status
848        .leader
849        .as_ref()
850        .map(|node_id| LeaderView::new(node_id.clone(), status.term, status.epoch))
851}
852
853fn member_role_label(role: MemberRole) -> &'static str {
854    match role {
855        MemberRole::Local => "local",
856        MemberRole::Client => "client",
857        MemberRole::Member => "member",
858    }
859}
860
861fn reachability_label(reachability: Reachability) -> &'static str {
862    match reachability {
863        Reachability::Reachable => "reachable",
864        Reachability::Suspect => "suspect",
865        Reachability::Unreachable => "unreachable",
866    }
867}
868
869fn overview_cluster_grid_counters(
870    mut left: ClusterGridCounters,
871    right: ClusterGridCounters,
872) -> ClusterGridCounters {
873    left.under_replicated_keys = left
874        .under_replicated_keys
875        .saturating_add(right.under_replicated_keys);
876    left.consistency_level_operations_total = left
877        .consistency_level_operations_total
878        .saturating_add(right.consistency_level_operations_total);
879    left
880}