mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-30 22:53:59 +00:00 
			
		
		
		
	Implement a memory cache for dashboard entries to avoid frequent disk reads (#5687)
This commit is contained in:
		| @@ -67,6 +67,9 @@ class DashboardSettings: | |||||||
|         self.on_ha_addon = False |         self.on_ha_addon = False | ||||||
|         self.cookie_secret = None |         self.cookie_secret = None | ||||||
|         self.absolute_config_dir = None |         self.absolute_config_dir = None | ||||||
|  |         self._entry_cache: dict[ | ||||||
|  |             str, tuple[tuple[int, int, float, int], DashboardEntry] | ||||||
|  |         ] = {} | ||||||
|  |  | ||||||
|     def parse_args(self, args): |     def parse_args(self, args): | ||||||
|         self.on_ha_addon = args.ha_addon |         self.on_ha_addon = args.ha_addon | ||||||
| @@ -121,9 +124,70 @@ class DashboardSettings: | |||||||
|         Path(joined_path).resolve().relative_to(self.absolute_config_dir) |         Path(joined_path).resolve().relative_to(self.absolute_config_dir) | ||||||
|         return joined_path |         return joined_path | ||||||
|  |  | ||||||
|     def list_yaml_files(self): |     def list_yaml_files(self) -> list[str]: | ||||||
|         return util.list_yaml_files([self.config_dir]) |         return util.list_yaml_files([self.config_dir]) | ||||||
|  |  | ||||||
|  |     def entries(self) -> list[DashboardEntry]: | ||||||
|  |         """Fetch all dashboard entries, thread-safe.""" | ||||||
|  |         path_to_cache_key: dict[str, tuple[int, int, float, int]] = {} | ||||||
|  |         # | ||||||
|  |         # The cache key is (inode, device, mtime, size) | ||||||
|  |         # which allows us to avoid locking since it ensures | ||||||
|  |         # every iteration of this call will always return the newest | ||||||
|  |         # items from disk at the cost of a stat() call on each | ||||||
|  |         # file which is much faster than reading the file | ||||||
|  |         # for the cache hit case which is the common case. | ||||||
|  |         # | ||||||
|  |         # Because there is no lock the cache may | ||||||
|  |         # get built more than once but that's fine as its still | ||||||
|  |         # thread-safe and results in orders of magnitude less | ||||||
|  |         # reads from disk than if we did not cache at all and | ||||||
|  |         # does not have a lock contention issue. | ||||||
|  |         # | ||||||
|  |         for file in self.list_yaml_files(): | ||||||
|  |             try: | ||||||
|  |                 # Prefer the json storage path if it exists | ||||||
|  |                 stat = os.stat(ext_storage_path(os.path.basename(file))) | ||||||
|  |             except OSError: | ||||||
|  |                 try: | ||||||
|  |                     # Fallback to the yaml file if the storage | ||||||
|  |                     # file does not exist or could not be generated | ||||||
|  |                     stat = os.stat(file) | ||||||
|  |                 except OSError: | ||||||
|  |                     # File was deleted, ignore | ||||||
|  |                     continue | ||||||
|  |             path_to_cache_key[file] = ( | ||||||
|  |                 stat.st_ino, | ||||||
|  |                 stat.st_dev, | ||||||
|  |                 stat.st_mtime, | ||||||
|  |                 stat.st_size, | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         entry_cache = self._entry_cache | ||||||
|  |  | ||||||
|  |         # Remove entries that no longer exist | ||||||
|  |         removed: list[str] = [] | ||||||
|  |         for file in entry_cache: | ||||||
|  |             if file not in path_to_cache_key: | ||||||
|  |                 removed.append(file) | ||||||
|  |  | ||||||
|  |         for file in removed: | ||||||
|  |             entry_cache.pop(file) | ||||||
|  |  | ||||||
|  |         dashboard_entries: list[DashboardEntry] = [] | ||||||
|  |         for file, cache_key in path_to_cache_key.items(): | ||||||
|  |             if cached_entry := entry_cache.get(file): | ||||||
|  |                 entry_key, dashboard_entry = cached_entry | ||||||
|  |                 if entry_key == cache_key: | ||||||
|  |                     dashboard_entries.append(dashboard_entry) | ||||||
|  |                     continue | ||||||
|  |  | ||||||
|  |             dashboard_entry = DashboardEntry(file) | ||||||
|  |             dashboard_entries.append(dashboard_entry) | ||||||
|  |             entry_cache[file] = (cache_key, dashboard_entry) | ||||||
|  |  | ||||||
|  |         return dashboard_entries | ||||||
|  |  | ||||||
|  |  | ||||||
| settings = DashboardSettings() | settings = DashboardSettings() | ||||||
|  |  | ||||||
| @@ -657,18 +721,26 @@ class EsphomeVersionHandler(BaseHandler): | |||||||
|         self.finish() |         self.finish() | ||||||
|  |  | ||||||
|  |  | ||||||
| def _list_dashboard_entries(): | def _list_dashboard_entries() -> list[DashboardEntry]: | ||||||
|     files = settings.list_yaml_files() |     return settings.entries() | ||||||
|     return [DashboardEntry(file) for file in files] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class DashboardEntry: | class DashboardEntry: | ||||||
|     def __init__(self, path): |     """Represents a single dashboard entry. | ||||||
|  |  | ||||||
|  |     This class is thread-safe and read-only. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     __slots__ = ("path", "_storage", "_loaded_storage") | ||||||
|  |  | ||||||
|  |     def __init__(self, path: str) -> None: | ||||||
|  |         """Initialize the DashboardEntry.""" | ||||||
|         self.path = path |         self.path = path | ||||||
|         self._storage = None |         self._storage = None | ||||||
|         self._loaded_storage = False |         self._loaded_storage = False | ||||||
|  |  | ||||||
|     def __repr__(self): |     def __repr__(self): | ||||||
|  |         """Return the representation of this entry.""" | ||||||
|         return ( |         return ( | ||||||
|             f"DashboardEntry({self.path} " |             f"DashboardEntry({self.path} " | ||||||
|             f"address={self.address} " |             f"address={self.address} " | ||||||
| @@ -679,10 +751,12 @@ class DashboardEntry: | |||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def filename(self): |     def filename(self): | ||||||
|  |         """Return the filename of this entry.""" | ||||||
|         return os.path.basename(self.path) |         return os.path.basename(self.path) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def storage(self) -> StorageJSON | None: |     def storage(self) -> StorageJSON | None: | ||||||
|  |         """Return the StorageJSON object for this entry.""" | ||||||
|         if not self._loaded_storage: |         if not self._loaded_storage: | ||||||
|             self._storage = StorageJSON.load(ext_storage_path(self.filename)) |             self._storage = StorageJSON.load(ext_storage_path(self.filename)) | ||||||
|             self._loaded_storage = True |             self._loaded_storage = True | ||||||
| @@ -690,48 +764,56 @@ class DashboardEntry: | |||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def address(self): |     def address(self): | ||||||
|  |         """Return the address of this entry.""" | ||||||
|         if self.storage is None: |         if self.storage is None: | ||||||
|             return None |             return None | ||||||
|         return self.storage.address |         return self.storage.address | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def no_mdns(self): |     def no_mdns(self): | ||||||
|  |         """Return the no_mdns of this entry.""" | ||||||
|         if self.storage is None: |         if self.storage is None: | ||||||
|             return None |             return None | ||||||
|         return self.storage.no_mdns |         return self.storage.no_mdns | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def web_port(self): |     def web_port(self): | ||||||
|  |         """Return the web port of this entry.""" | ||||||
|         if self.storage is None: |         if self.storage is None: | ||||||
|             return None |             return None | ||||||
|         return self.storage.web_port |         return self.storage.web_port | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def name(self): |     def name(self): | ||||||
|  |         """Return the name of this entry.""" | ||||||
|         if self.storage is None: |         if self.storage is None: | ||||||
|             return self.filename.replace(".yml", "").replace(".yaml", "") |             return self.filename.replace(".yml", "").replace(".yaml", "") | ||||||
|         return self.storage.name |         return self.storage.name | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def friendly_name(self): |     def friendly_name(self): | ||||||
|  |         """Return the friendly name of this entry.""" | ||||||
|         if self.storage is None: |         if self.storage is None: | ||||||
|             return self.name |             return self.name | ||||||
|         return self.storage.friendly_name |         return self.storage.friendly_name | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def comment(self): |     def comment(self): | ||||||
|  |         """Return the comment of this entry.""" | ||||||
|         if self.storage is None: |         if self.storage is None: | ||||||
|             return None |             return None | ||||||
|         return self.storage.comment |         return self.storage.comment | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def target_platform(self): |     def target_platform(self): | ||||||
|  |         """Return the target platform of this entry.""" | ||||||
|         if self.storage is None: |         if self.storage is None: | ||||||
|             return None |             return None | ||||||
|         return self.storage.target_platform |         return self.storage.target_platform | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def update_available(self): |     def update_available(self): | ||||||
|  |         """Return if an update is available for this entry.""" | ||||||
|         if self.storage is None: |         if self.storage is None: | ||||||
|             return True |             return True | ||||||
|         return self.update_old != self.update_new |         return self.update_old != self.update_new | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user