Skip to main content

common_meta/
datanode.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::{HashMap, HashSet};
16use std::str::FromStr;
17
18use api::v1::meta::{DatanodeWorkloads, HeartbeatRequest, RequestHeader};
19use common_time::util as time_util;
20use lazy_static::lazy_static;
21use regex::Regex;
22use serde::{Deserialize, Serialize};
23use snafu::{OptionExt, ResultExt, ensure};
24use store_api::region_engine::{RegionRole, RegionStatistic};
25use store_api::storage::RegionId;
26use table::metadata::TableId;
27
28use crate::error::{self, DeserializeFromJsonSnafu, Result};
29use crate::heartbeat::utils::get_datanode_workloads;
30
31const DATANODE_STAT_PREFIX: &str = "__meta_datanode_stat";
32
33pub const REGION_STATISTIC_KEY: &str = "__region_statistic";
34
35lazy_static! {
36    pub(crate) static ref DATANODE_LEASE_KEY_PATTERN: Regex =
37        Regex::new("^__meta_datanode_lease-([0-9]+)-([0-9]+)$").unwrap();
38    static ref DATANODE_STAT_KEY_PATTERN: Regex =
39        Regex::new(&format!("^{DATANODE_STAT_PREFIX}-([0-9]+)-([0-9]+)$")).unwrap();
40    static ref INACTIVE_REGION_KEY_PATTERN: Regex =
41        Regex::new("^__meta_inactive_region-([0-9]+)-([0-9]+)-([0-9]+)$").unwrap();
42}
43
44/// The key of the datanode stat in the storage.
45///
46/// The format is `__meta_datanode_stat-0-{node_id}`.
47#[derive(Debug, Clone, Default, Serialize, Deserialize)]
48pub struct Stat {
49    pub timestamp_millis: i64,
50    // The datanode Id.
51    pub id: u64,
52    // The datanode address.
53    pub addr: String,
54    /// The read capacity units during this period
55    pub rcus: i64,
56    /// The write capacity units during this period
57    pub wcus: i64,
58    /// How many regions on this node
59    pub region_num: u64,
60    /// The region stats of the datanode.
61    pub region_stats: Vec<RegionStat>,
62    /// The topic stats of the datanode.
63    pub topic_stats: Vec<TopicStat>,
64    // The node epoch is used to check whether the node has restarted or redeployed.
65    pub node_epoch: u64,
66    /// The datanode workloads.
67    pub datanode_workloads: DatanodeWorkloads,
68    /// The GC statistics of the datanode.
69    pub gc_stat: Option<GcStat>,
70}
71
72/// The statistics of a region.
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74pub struct RegionStat {
75    /// The region_id.
76    pub id: RegionId,
77    /// The read capacity units during this period
78    pub rcus: i64,
79    /// The write capacity units during this period
80    pub wcus: i64,
81    /// Approximate disk bytes of this region, including sst, index, manifest and wal
82    pub approximate_bytes: u64,
83    /// The engine name.
84    pub engine: String,
85    /// The region role.
86    pub role: RegionRole,
87    /// The number of rows
88    pub num_rows: u64,
89    /// The size of the memtable in bytes.
90    pub memtable_size: u64,
91    /// The size of the manifest in bytes.
92    pub manifest_size: u64,
93    /// The size of the SST data files in bytes.
94    pub sst_size: u64,
95    /// The num of the SST data files.
96    pub sst_num: u64,
97    /// The size of the SST index files in bytes.
98    pub index_size: u64,
99    /// The manifest infoof the region.
100    pub region_manifest: RegionManifestInfo,
101    /// The total bytes written of the region since region opened.
102    pub written_bytes: u64,
103    /// The total query CPU time of the region since region opened.
104    ///
105    /// Unit: nanoseconds.
106    #[serde(default)]
107    pub query_cpu_time: u64,
108    /// The total scanned bytes of the region since region opened.
109    #[serde(default)]
110    pub query_scanned_bytes: u64,
111    /// The latest entry id of topic used by data.
112    /// **Only used by remote WAL prune.**
113    pub data_topic_latest_entry_id: u64,
114    /// The latest entry id of topic used by metadata.
115    /// **Only used by remote WAL prune.**
116    /// In mito engine, this is the same as `data_topic_latest_entry_id`.
117    pub metadata_topic_latest_entry_id: u64,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct TopicStat {
122    /// The topic name.
123    pub topic: String,
124    /// The latest entry id of the topic.
125    pub latest_entry_id: u64,
126    /// The total size in bytes of records appended to the topic.
127    pub record_size: u64,
128    /// The total number of records appended to the topic.
129    pub record_num: u64,
130}
131
132/// Trait for reporting statistics about topics.
133pub trait TopicStatsReporter: Send + Sync {
134    /// Returns a list of topic statistics that can be reported.
135    fn reportable_topics(&mut self) -> Vec<TopicStat>;
136}
137
138#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
139pub enum RegionManifestInfo {
140    Mito {
141        manifest_version: u64,
142        flushed_entry_id: u64,
143        /// Number of files removed in the manifest's `removed_files` field.
144        file_removed_cnt: u64,
145    },
146    Metric {
147        data_manifest_version: u64,
148        data_flushed_entry_id: u64,
149        metadata_manifest_version: u64,
150        metadata_flushed_entry_id: u64,
151    },
152}
153
154impl Stat {
155    #[inline]
156    pub fn is_empty(&self) -> bool {
157        self.region_stats.is_empty()
158    }
159
160    pub fn stat_key(&self) -> DatanodeStatKey {
161        DatanodeStatKey { node_id: self.id }
162    }
163
164    /// Returns a tuple array containing [RegionId] and [RegionRole].
165    pub fn regions(&self) -> Vec<(RegionId, RegionRole)> {
166        self.region_stats.iter().map(|s| (s.id, s.role)).collect()
167    }
168
169    /// Returns all table ids in the region stats.
170    pub fn table_ids(&self) -> HashSet<TableId> {
171        self.region_stats.iter().map(|s| s.id.table_id()).collect()
172    }
173
174    /// Retains the active region stats and updates the rcus, wcus, and region_num.
175    pub fn retain_active_region_stats(&mut self, inactive_region_ids: &HashSet<RegionId>) {
176        if inactive_region_ids.is_empty() {
177            return;
178        }
179
180        self.region_stats
181            .retain(|r| !inactive_region_ids.contains(&r.id));
182        self.rcus = self.region_stats.iter().map(|s| s.rcus).sum();
183        self.wcus = self.region_stats.iter().map(|s| s.wcus).sum();
184        self.region_num = self.region_stats.len() as u64;
185    }
186
187    pub fn memory_size(&self) -> usize {
188        // timestamp_millis, rcus, wcus
189        std::mem::size_of::<i64>() * 3 +
190        // id, region_num, node_epoch
191        std::mem::size_of::<u64>() * 3 +
192        // addr
193        std::mem::size_of::<String>() + self.addr.capacity() +
194        // region_stats
195        self.region_stats.iter().map(|s| s.memory_size()).sum::<usize>()
196    }
197}
198
199impl RegionStat {
200    pub fn memory_size(&self) -> usize {
201        // role
202        std::mem::size_of::<RegionRole>() +
203        // id
204        std::mem::size_of::<RegionId>() +
205        // rcus, wcus, approximate_bytes, num_rows
206        std::mem::size_of::<i64>() * 4 +
207        // memtable_size, manifest_size, sst_size, sst_num, index_size
208        std::mem::size_of::<u64>() * 5 +
209        // engine
210        std::mem::size_of::<String>() + self.engine.capacity() +
211        // region_manifest
212        self.region_manifest.memory_size()
213    }
214}
215
216impl RegionManifestInfo {
217    pub fn memory_size(&self) -> usize {
218        match self {
219            RegionManifestInfo::Mito { .. } => std::mem::size_of::<u64>() * 2,
220            RegionManifestInfo::Metric { .. } => std::mem::size_of::<u64>() * 4,
221        }
222    }
223}
224
225impl TryFrom<&HeartbeatRequest> for Stat {
226    type Error = Option<RequestHeader>;
227
228    fn try_from(value: &HeartbeatRequest) -> std::result::Result<Self, Self::Error> {
229        let HeartbeatRequest {
230            header,
231            peer,
232            region_stats,
233            node_epoch,
234            node_workloads,
235            topic_stats,
236            extensions,
237            ..
238        } = value;
239
240        match (header, peer) {
241            (Some(header), Some(peer)) => {
242                let region_stats = region_stats
243                    .iter()
244                    .map(RegionStat::from)
245                    .collect::<Vec<_>>();
246                let topic_stats = topic_stats.iter().map(TopicStat::from).collect::<Vec<_>>();
247
248                let datanode_workloads = get_datanode_workloads(node_workloads.as_ref());
249
250                let gc_stat = GcStat::from_extensions(extensions).map_err(|err| {
251                    common_telemetry::error!(
252                        "Failed to deserialize GcStat from extensions: {}",
253                        err
254                    );
255                    header.clone()
256                })?;
257                Ok(Self {
258                    timestamp_millis: time_util::current_time_millis(),
259                    // datanode id
260                    id: peer.id,
261                    // datanode address
262                    addr: peer.addr.clone(),
263                    rcus: region_stats.iter().map(|s| s.rcus).sum(),
264                    wcus: region_stats.iter().map(|s| s.wcus).sum(),
265                    region_num: region_stats.len() as u64,
266                    region_stats,
267                    topic_stats,
268                    node_epoch: *node_epoch,
269                    datanode_workloads,
270                    gc_stat,
271                })
272            }
273            (header, _) => Err(header.clone()),
274        }
275    }
276}
277
278impl From<store_api::region_engine::RegionManifestInfo> for RegionManifestInfo {
279    fn from(value: store_api::region_engine::RegionManifestInfo) -> Self {
280        match value {
281            store_api::region_engine::RegionManifestInfo::Mito {
282                manifest_version,
283                flushed_entry_id,
284                file_removed_cnt,
285            } => RegionManifestInfo::Mito {
286                manifest_version,
287                flushed_entry_id,
288                file_removed_cnt,
289            },
290            store_api::region_engine::RegionManifestInfo::Metric {
291                data_manifest_version,
292                data_flushed_entry_id,
293                metadata_manifest_version,
294                metadata_flushed_entry_id,
295            } => RegionManifestInfo::Metric {
296                data_manifest_version,
297                data_flushed_entry_id,
298                metadata_manifest_version,
299                metadata_flushed_entry_id,
300            },
301        }
302    }
303}
304
305impl From<&api::v1::meta::RegionStat> for RegionStat {
306    fn from(value: &api::v1::meta::RegionStat) -> Self {
307        let region_stat = value
308            .extensions
309            .get(REGION_STATISTIC_KEY)
310            .and_then(|value| RegionStatistic::deserialize_from_slice(value))
311            .unwrap_or_default();
312
313        Self {
314            id: RegionId::from_u64(value.region_id),
315            rcus: value.rcus,
316            wcus: value.wcus,
317            approximate_bytes: value.approximate_bytes as u64,
318            engine: value.engine.clone(),
319            role: RegionRole::from(value.role()),
320            num_rows: region_stat.num_rows,
321            memtable_size: region_stat.memtable_size,
322            manifest_size: region_stat.manifest_size,
323            sst_size: region_stat.sst_size,
324            sst_num: region_stat.sst_num,
325            index_size: region_stat.index_size,
326            region_manifest: region_stat.manifest.into(),
327            written_bytes: region_stat.written_bytes,
328            query_cpu_time: region_stat.query_cpu_time,
329            query_scanned_bytes: region_stat.query_scanned_bytes,
330            data_topic_latest_entry_id: region_stat.data_topic_latest_entry_id,
331            metadata_topic_latest_entry_id: region_stat.metadata_topic_latest_entry_id,
332        }
333    }
334}
335
336impl From<&api::v1::meta::TopicStat> for TopicStat {
337    fn from(value: &api::v1::meta::TopicStat) -> Self {
338        Self {
339            topic: value.topic_name.clone(),
340            latest_entry_id: value.latest_entry_id,
341            record_size: value.record_size,
342            record_num: value.record_num,
343        }
344    }
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize, Default)]
348pub struct GcStat {
349    /// Number of GC tasks currently running on the datanode.
350    pub running_gc_tasks: u32,
351    /// The maximum number of concurrent GC tasks the datanode can handle.
352    pub gc_concurrency: u32,
353}
354
355impl GcStat {
356    pub const GC_STAT_KEY: &str = "__gc_stat";
357
358    pub fn new(running_gc_tasks: u32, gc_concurrency: u32) -> Self {
359        Self {
360            running_gc_tasks,
361            gc_concurrency,
362        }
363    }
364
365    pub fn into_extensions(&self, extensions: &mut std::collections::HashMap<String, Vec<u8>>) {
366        let bytes = serde_json::to_vec(self).unwrap_or_default();
367        extensions.insert(Self::GC_STAT_KEY.to_string(), bytes);
368    }
369
370    pub fn from_extensions(
371        extensions: &std::collections::HashMap<String, Vec<u8>>,
372    ) -> Result<Option<Self>> {
373        extensions
374            .get(Self::GC_STAT_KEY)
375            .map(|bytes| {
376                serde_json::from_slice(bytes).with_context(|_| DeserializeFromJsonSnafu {
377                    input: String::from_utf8_lossy(bytes).to_string(),
378                })
379            })
380            .transpose()
381    }
382}
383
384/// Environment variables reported by a node in heartbeat messages.
385#[derive(Debug, Clone, Serialize, Deserialize, Default)]
386pub struct EnvVars {
387    pub vars: HashMap<String, String>,
388}
389
390impl EnvVars {
391    pub const ENV_VARS_KEY: &str = "__env_vars";
392
393    pub fn new(vars: HashMap<String, String>) -> Self {
394        Self { vars }
395    }
396
397    /// Read the configured env var keys from the environment and build an EnvVars.
398    pub fn from_config(keys: &[String]) -> Self {
399        let vars = keys
400            .iter()
401            .filter_map(|key| std::env::var(key).ok().map(|value| (key.clone(), value)))
402            .collect();
403        Self { vars }
404    }
405
406    pub fn into_extensions(&self, extensions: &mut HashMap<String, Vec<u8>>) {
407        if self.vars.is_empty() {
408            return;
409        }
410        let bytes = serde_json::to_vec(self).unwrap_or_default();
411        extensions.insert(Self::ENV_VARS_KEY.to_string(), bytes);
412    }
413
414    pub fn from_extensions(extensions: &HashMap<String, Vec<u8>>) -> Result<Option<Self>> {
415        extensions
416            .get(Self::ENV_VARS_KEY)
417            .map(|bytes| {
418                serde_json::from_slice(bytes).with_context(|_| DeserializeFromJsonSnafu {
419                    input: String::from_utf8_lossy(bytes).to_string(),
420                })
421            })
422            .transpose()
423    }
424}
425
426/// The key of the datanode stat in the memory store.
427///
428/// The format is `__meta_datanode_stat-0-{node_id}`.
429#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
430pub struct DatanodeStatKey {
431    pub node_id: u64,
432}
433
434impl DatanodeStatKey {
435    /// The key prefix.
436    pub fn prefix_key() -> Vec<u8> {
437        // todo(hl): remove cluster id in prefix
438        format!("{DATANODE_STAT_PREFIX}-0-").into_bytes()
439    }
440}
441
442impl From<DatanodeStatKey> for Vec<u8> {
443    fn from(value: DatanodeStatKey) -> Self {
444        // todo(hl): remove cluster id in prefix
445        format!("{}-0-{}", DATANODE_STAT_PREFIX, value.node_id).into_bytes()
446    }
447}
448
449impl FromStr for DatanodeStatKey {
450    type Err = error::Error;
451
452    fn from_str(key: &str) -> Result<Self> {
453        let caps = DATANODE_STAT_KEY_PATTERN
454            .captures(key)
455            .context(error::InvalidStatKeySnafu { key })?;
456
457        ensure!(caps.len() == 3, error::InvalidStatKeySnafu { key });
458        let node_id = caps[2].to_string();
459        let node_id: u64 = node_id.parse().context(error::ParseNumSnafu {
460            err_msg: format!("invalid node_id: {node_id}"),
461        })?;
462
463        Ok(Self { node_id })
464    }
465}
466
467impl TryFrom<Vec<u8>> for DatanodeStatKey {
468    type Error = error::Error;
469
470    fn try_from(bytes: Vec<u8>) -> Result<Self> {
471        String::from_utf8(bytes)
472            .context(error::FromUtf8Snafu {
473                name: "DatanodeStatKey",
474            })
475            .map(|x| x.parse())?
476    }
477}
478
479/// The value of the datanode stat in the memory store.
480#[derive(Debug, Clone, Serialize, Deserialize)]
481#[serde(transparent)]
482pub struct DatanodeStatValue {
483    pub stats: Vec<Stat>,
484}
485
486impl DatanodeStatValue {
487    /// Get the latest number of regions.
488    pub fn region_num(&self) -> Option<u64> {
489        self.stats.last().map(|x| x.region_num)
490    }
491
492    /// Get the latest node addr.
493    pub fn node_addr(&self) -> Option<String> {
494        self.stats.last().map(|x| x.addr.clone())
495    }
496}
497
498impl TryFrom<DatanodeStatValue> for Vec<u8> {
499    type Error = error::Error;
500
501    fn try_from(stats: DatanodeStatValue) -> Result<Self> {
502        Ok(serde_json::to_string(&stats)
503            .context(error::SerializeToJsonSnafu {
504                input: format!("{stats:?}"),
505            })?
506            .into_bytes())
507    }
508}
509
510impl FromStr for DatanodeStatValue {
511    type Err = error::Error;
512
513    fn from_str(value: &str) -> Result<Self> {
514        serde_json::from_str(value).context(error::DeserializeFromJsonSnafu { input: value })
515    }
516}
517
518impl TryFrom<Vec<u8>> for DatanodeStatValue {
519    type Error = error::Error;
520
521    fn try_from(value: Vec<u8>) -> Result<Self> {
522        String::from_utf8(value)
523            .context(error::FromUtf8Snafu {
524                name: "DatanodeStatValue",
525            })
526            .map(|x| x.parse())?
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn test_stat_key() {
536        let stat = Stat {
537            id: 101,
538            region_num: 10,
539            ..Default::default()
540        };
541
542        let stat_key = stat.stat_key();
543
544        assert_eq!(101, stat_key.node_id);
545    }
546
547    #[test]
548    fn test_stat_val_round_trip() {
549        let stat = Stat {
550            id: 101,
551            region_num: 100,
552            ..Default::default()
553        };
554
555        let stat_val = DatanodeStatValue { stats: vec![stat] };
556
557        let bytes: Vec<u8> = stat_val.try_into().unwrap();
558        let stat_val: DatanodeStatValue = bytes.try_into().unwrap();
559        let stats = stat_val.stats;
560
561        assert_eq!(1, stats.len());
562
563        let stat = stats.first().unwrap();
564        assert_eq!(101, stat.id);
565        assert_eq!(100, stat.region_num);
566    }
567
568    #[test]
569    fn test_stat_val_deserializes_without_query_stats() {
570        let stat = Stat {
571            region_stats: vec![RegionStat {
572                id: RegionId::new(1024, 1),
573                rcus: 0,
574                wcus: 0,
575                approximate_bytes: 0,
576                engine: "mito".to_string(),
577                role: RegionRole::Leader,
578                num_rows: 0,
579                memtable_size: 0,
580                manifest_size: 0,
581                sst_size: 0,
582                sst_num: 0,
583                index_size: 0,
584                region_manifest: RegionManifestInfo::Mito {
585                    manifest_version: 0,
586                    flushed_entry_id: 0,
587                    file_removed_cnt: 0,
588                },
589                written_bytes: 0,
590                query_cpu_time: 10,
591                query_scanned_bytes: 20,
592                data_topic_latest_entry_id: 0,
593                metadata_topic_latest_entry_id: 0,
594            }],
595            ..Default::default()
596        };
597        let stat_val = DatanodeStatValue { stats: vec![stat] };
598        let mut value = serde_json::to_value(stat_val).unwrap();
599        let region_stat = value[0]["region_stats"][0].as_object_mut().unwrap();
600        region_stat.remove("query_cpu_time");
601        region_stat.remove("query_scanned_bytes");
602
603        let stat_val: DatanodeStatValue = serde_json::from_value(value).unwrap();
604        let region_stat = &stat_val.stats[0].region_stats[0];
605
606        assert_eq!(region_stat.query_cpu_time, 0);
607        assert_eq!(region_stat.query_scanned_bytes, 0);
608    }
609
610    #[test]
611    fn test_get_addr_from_stat_val() {
612        let empty = DatanodeStatValue { stats: vec![] };
613        let addr = empty.node_addr();
614        assert!(addr.is_none());
615
616        let stat_val = DatanodeStatValue {
617            stats: vec![
618                Stat {
619                    addr: "1".to_string(),
620                    ..Default::default()
621                },
622                Stat {
623                    addr: "2".to_string(),
624                    ..Default::default()
625                },
626                Stat {
627                    addr: "3".to_string(),
628                    ..Default::default()
629                },
630            ],
631        };
632        let addr = stat_val.node_addr().unwrap();
633        assert_eq!("3", addr);
634    }
635
636    #[test]
637    fn test_get_region_num_from_stat_val() {
638        let empty = DatanodeStatValue { stats: vec![] };
639        let region_num = empty.region_num();
640        assert!(region_num.is_none());
641
642        let wrong = DatanodeStatValue {
643            stats: vec![Stat {
644                region_num: 0,
645                ..Default::default()
646            }],
647        };
648        let right = wrong.region_num();
649        assert_eq!(Some(0), right);
650
651        let stat_val = DatanodeStatValue {
652            stats: vec![
653                Stat {
654                    region_num: 1,
655                    ..Default::default()
656                },
657                Stat {
658                    region_num: 0,
659                    ..Default::default()
660                },
661                Stat {
662                    region_num: 2,
663                    ..Default::default()
664                },
665            ],
666        };
667        let region_num = stat_val.region_num().unwrap();
668        assert_eq!(2, region_num);
669    }
670
671    #[test]
672    fn test_region_stat_from_heartbeat_preserves_staging_leader_role() {
673        let request = HeartbeatRequest {
674            header: Some(RequestHeader::default()),
675            peer: Some(api::v1::meta::Peer {
676                id: 1,
677                addr: "127.0.0.1:3001".to_string(),
678            }),
679            region_stats: vec![api::v1::meta::RegionStat {
680                region_id: RegionId::new(1024, 1).as_u64(),
681                engine: "mito".to_string(),
682                role: api::v1::meta::RegionRole::StagingLeader.into(),
683                ..Default::default()
684            }],
685            ..Default::default()
686        };
687
688        let stat = Stat::try_from(&request).unwrap();
689
690        assert_eq!(stat.region_stats.len(), 1);
691        assert_eq!(stat.region_stats[0].role, RegionRole::StagingLeader);
692    }
693
694    #[test]
695    fn test_env_vars_round_trip() {
696        let mut vars = HashMap::new();
697        vars.insert("AZ".to_string(), "us-east-1a".to_string());
698        vars.insert("REGION".to_string(), "us-east-1".to_string());
699        let env_vars = EnvVars::new(vars);
700
701        let mut extensions = HashMap::new();
702        env_vars.into_extensions(&mut extensions);
703
704        let extracted = EnvVars::from_extensions(&extensions).unwrap().unwrap();
705        assert_eq!(extracted.vars.get("AZ").unwrap(), "us-east-1a");
706        assert_eq!(extracted.vars.get("REGION").unwrap(), "us-east-1");
707    }
708
709    #[test]
710    fn test_env_vars_empty_not_written() {
711        let env_vars = EnvVars::default();
712        let mut extensions = HashMap::new();
713        env_vars.into_extensions(&mut extensions);
714        assert!(extensions.is_empty());
715    }
716
717    #[test]
718    fn test_env_vars_from_extensions_missing() {
719        let extensions = HashMap::new();
720        let result = EnvVars::from_extensions(&extensions).unwrap();
721        assert!(result.is_none());
722    }
723}