Implement a memory cache for dashboard entries to avoid frequent disk reads (#5687)

This commit is contained in:
J. Nick Koston 2023-11-07 00:04:55 -06:00 committed by GitHub
parent 31fec2d692
commit 78e3ce7718
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23

View file

@ -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