This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new c65bedf2d76 fix(extensions): make LOCAL_EXTENSIONS hot reload reliable
in Docker (#40084)
c65bedf2d76 is described below
commit c65bedf2d760dab5758ccdaefa4bf751cd498f35
Author: Evan Rusackas <[email protected]>
AuthorDate: Fri Jul 24 15:30:04 2026 -0700
fix(extensions): make LOCAL_EXTENSIONS hot reload reliable in Docker
(#40084)
Co-authored-by: Amin Ghadersohi <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
---
.gitignore | 1 +
docker-compose.yml | 1 +
docker/docker-bootstrap.sh | 4 +-
superset/extensions/api.py | 2 +-
superset/extensions/cache_middleware.py | 6 +-
superset/extensions/local_extensions_watcher.py | 211 +++++++++++++++++++--
superset/extensions/utils.py | 9 +-
.../unit_tests/extensions/test_cache_middleware.py | 15 ++
.../extensions/test_local_extensions_watcher.py | 59 ++++++
9 files changed, 287 insertions(+), 21 deletions(-)
diff --git a/.gitignore b/.gitignore
index 6f1b2261d8a..2c721332df7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,6 +57,7 @@ local_config.py
/superset_text.yml
superset.egg-info/
superset/bin/supersetc
+superset/extensions/.reload_trigger
tmp
rat-results.txt
superset/app/
diff --git a/docker-compose.yml b/docker-compose.yml
index d522b66a664..d3a79aeef90 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -34,6 +34,7 @@ x-superset-volumes: &superset-volumes
- superset_home:/app/superset_home
- ./tests:/app/tests
- superset_data:/app/data
+ - ./local_extensions:/app/local_extensions
x-common-build: &common-build
context: .
target: ${SUPERSET_BUILD_TARGET:-dev} # can use `dev` (default) or `lean`
diff --git a/docker/docker-bootstrap.sh b/docker/docker-bootstrap.sh
index 8eec76cbcd0..b909b3b5092 100755
--- a/docker/docker-bootstrap.sh
+++ b/docker/docker-bootstrap.sh
@@ -98,7 +98,9 @@ case "${1}" in
echo " 🔒 Werkzeug debugger disabled (set SUPERSET_DEBUG_ENABLED=true
to enable)"
fi
- flask run -p $PORT --reload $DEBUGGER_FLAG --host=0.0.0.0
--exclude-patterns
"*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
+ flask run -p $PORT --reload $DEBUGGER_FLAG --host=0.0.0.0 \
+ --extra-files "/app/superset/extensions/.reload_trigger" \
+ --exclude-patterns
"*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*:*/superset/__init__.py"
;;
app-gunicorn)
echo "Starting web app..."
diff --git a/superset/extensions/api.py b/superset/extensions/api.py
index b1b5734979e..8ed3129d34c 100644
--- a/superset/extensions/api.py
+++ b/superset/extensions/api.py
@@ -169,7 +169,7 @@ class ExtensionsRestApi(BaseApi):
@protect()
@safe
- @expose("/<publisher>/<name>/<file>", methods=("GET",))
+ @expose("/<publisher>/<name>/<path:file>", methods=("GET",))
def content(self, publisher: str, name: str, file: str) -> Response:
"""Get a frontend chunk of an extension.
---
diff --git a/superset/extensions/cache_middleware.py
b/superset/extensions/cache_middleware.py
index e8a134052a0..c688bbe8d9f 100644
--- a/superset/extensions/cache_middleware.py
+++ b/superset/extensions/cache_middleware.py
@@ -23,9 +23,11 @@ from typing import Callable, Iterable, TYPE_CHECKING
if TYPE_CHECKING:
from _typeshed.wsgi import StartResponse, WSGIApplication, WSGIEnvironment
-# Matches only the static asset endpoint:
/api/v1/extensions/<publisher>/<name>/<file>
+# Matches only the static asset endpoint:
+# /api/v1/extensions/<publisher>/<name>/<path:file>, where the file portion may
+# contain nested segments (worker / WASM / chunk subfolders).
# Does not match the list (/), get (/<publisher>/<name>), or info (/_info)
endpoints.
-_ASSET_PATH_RE = re.compile(r"^/api/v1/extensions/[^/]+/[^/]+/[^/]+$")
+_ASSET_PATH_RE: re.Pattern[str] =
re.compile(r"^/api/v1/extensions/[^/]+/[^/]+/.+$")
class ExtensionCacheMiddleware:
diff --git a/superset/extensions/local_extensions_watcher.py
b/superset/extensions/local_extensions_watcher.py
index 6233f91fe5c..3cc1d00dfd4 100644
--- a/superset/extensions/local_extensions_watcher.py
+++ b/superset/extensions/local_extensions_watcher.py
@@ -21,7 +21,6 @@ from __future__ import annotations
import logging
import os
import threading
-import time
from pathlib import Path
from typing import Any
@@ -29,37 +28,207 @@ from flask import Flask
logger = logging.getLogger(__name__)
+# Sentinel file Flask watches via --extra-files. Touching it on a real change
+# triggers a server reload without depending on cwd or the location of any
+# Python source file.
+RELOAD_TRIGGER: Path = Path(__file__).resolve().parent / ".reload_trigger"
+
# Guard to prevent multiple initializations
_watcher_initialized = False
_watcher_lock = threading.Lock()
-def _get_file_handler_class() -> Any:
+def _get_file_handler_class() -> Any: # noqa: C901
"""Get the file handler class, importing watchdog only when needed."""
try:
- from watchdog.events import FileSystemEventHandler
+ import hashlib
+
+ from watchdog.events import (
+ FileCreatedEvent,
+ FileDeletedEvent,
+ FileModifiedEvent,
+ FileMovedEvent,
+ FileSystemEventHandler,
+ )
class LocalExtensionFileHandler(FileSystemEventHandler):
- """Custom file system event handler for LOCAL_EXTENSIONS
directories."""
+ """Custom file system event handler for LOCAL_EXTENSIONS
directories.
+
+ Only reacts to genuine content changes (create / modify / move) in
the
+ dist directory, verified by comparing a SHA-256 of the file's
content.
+ This avoids the Docker VirtioFS / osxfs problem where reading a
file
+ generates inotify events that watchdog surfaces as modifications.
+ """
+
+ def __init__(self) -> None:
+ super().__init__()
+ # sha256 of last-seen content, keyed by absolute path.
Populated
+ # from existing files in watched `dist` dirs at startup (see
+ # `prime_baseline`) so that startup-noise inotify events from
+ # Docker VirtioFS reads don't get treated as the first real
edit.
+ self._file_hashes: dict[str, str] = {}
+ self._lock: threading.Lock = threading.Lock()
+ # Trailing debounce: schedule a single reload after a quiet
+ # window so simultaneous webpack writes coalesce into one
+ # restart that fires *after* the build settles.
+ self._debounce_seconds: float = 1.0
+ self._pending_timer: threading.Timer | None = None
+ # Monotonically increasing token identifying the most recently
+ # scheduled timer. Guards the timer-already-fired race where
+ # `Timer.cancel()` can't stop a callback that has begun
running.
+ self._reload_generation: int = 0
+
+ # ── helpers ──────────────────────────────────────────────────────
+
+ @staticmethod
+ def _sha256(path: str) -> str | None:
+ try:
+ with open(path, "rb") as fh:
+ return hashlib.sha256(fh.read()).hexdigest()
+ except OSError:
+ return None
+
+ def prime_baseline(self, watch_dirs: set[str]) -> None:
+ """Pre-populate content hashes for existing files in watched
+ `dist` directories. Called once at watcher startup so a
+ developer's first real edit registers as a content change
+ rather than as the file's 'first observation'."""
+ for root_dir in watch_dirs:
+ root = Path(root_dir)
+ for path in root.rglob("*"):
+ if not path.is_file():
+ continue
+ if "dist" not in path.parts:
+ continue
+ digest = self._sha256(str(path))
+ if digest is not None:
+ self._file_hashes[str(path)] = digest
+
+ def _content_changed(self, path: str) -> bool:
+ """Return True when the file's content differs from last seen.
+
+ With `prime_baseline` called at startup, the baseline reflects
+ what was on disk when the watcher started. A first observation
+ that differs (or doesn't exist in baseline) is treated as a
+ genuine change.
+ """
+ digest = self._sha256(path)
+ if digest is None:
+ return False
+ old_digest = self._file_hashes.get(path)
+ self._file_hashes[path] = digest
+ # New file (not in baseline) is a real change; otherwise
compare.
+ return old_digest != digest
+
+ def _trigger_reload(self, source_path: str, generation: int) ->
None:
+ """Touch the reload-trigger sentinel; Flask's --extra-files
+ watcher reloads on its mtime change."""
+ # A newer event may have superseded this timer after it began
+ # running (`cancel()` can't stop an in-flight callback), so
only
+ # the most recently scheduled generation is allowed to fire.
The
+ # check and the sentinel touch happen inside the same critical
+ # section so a `_schedule_reload` call racing in from another
+ # thread can't bump the generation between the check and the
+ # write and let this stale callback still fire.
+ with self._lock:
+ if generation != self._reload_generation:
+ return
+ logger.info(
+ "File change settled in LOCAL_EXTENSIONS: %s",
source_path
+ )
+ logger.info("Triggering restart by touching %s",
RELOAD_TRIGGER)
+ try:
+ RELOAD_TRIGGER.touch()
+ except OSError as e:
+ logger.warning(
+ "Failed to touch reload trigger %s: %s",
RELOAD_TRIGGER, e
+ )
+
+ def _schedule_reload(self, source_path: str) -> None:
+ """Trailing-debounce: cancel any pending reload and schedule a
+ new one for `_debounce_seconds` from now. Each new event resets
+ the timer, so the reload fires only after a quiet window."""
+ with self._lock:
+ if self._pending_timer is not None:
+ self._pending_timer.cancel()
+ self._reload_generation += 1
+ timer = threading.Timer(
+ self._debounce_seconds,
+ self._trigger_reload,
+ args=(source_path, self._reload_generation),
+ )
+ timer.daemon = True
+ self._pending_timer = timer
+ timer.start()
+
+ def _handle_moved(self, event: Any) -> None:
+ """Moves into/out of `dist` are explicit signals — trigger
+ regardless of content match (the source may already be gone
+ or the destination may not have a meaningful hash yet).
+ Atomic-build workflows rename tmp -> dist (dest in dist),
+ while removing an artifact renames dist -> elsewhere (src in
+ dist); either side touching `dist` is a real signal."""
+ dest = getattr(event, "dest_path", None)
+ src = getattr(event, "src_path", None)
+ # The file no longer lives at the source path; evict its
+ # hash entry so the index only tracks paths that exist.
+ if isinstance(src, str):
+ self._file_hashes.pop(src, None)
+ dist_side = next(
+ (
+ p
+ for p in (dest, src)
+ if isinstance(p, str) and "dist" in Path(p).parts
+ ),
+ None,
+ )
+ if dist_side is not None:
+ self._schedule_reload(dist_side)
+
+ # ── event handler
─────────────────────────────────────────────────
def on_any_event(self, event: Any) -> None:
- """Handle any file system event in the watched directories."""
+ """Handle file system events in the watched directories."""
if event.is_directory:
return
- # Only trigger on changes to files in `dist` directory
- src = getattr(event, "src_path", None)
- if not isinstance(src, str) or "dist" not in Path(src).parts:
+ # Deletions don't trigger a reload (webpack clean steps delete
+ # old chunks right before writing new ones, which trigger via
+ # the subsequent create/modify), but the stale hash entry must
+ # be evicted so `_file_hashes` doesn't grow without bound as
+ # hashed chunk filenames churn across rebuilds.
+ if isinstance(event, FileDeletedEvent):
+ src = getattr(event, "src_path", None)
+ if isinstance(src, str):
+ self._file_hashes.pop(src, None)
return
- logger.info(
- "File change detected in LOCAL_EXTENSIONS: %s",
event.src_path
- )
+ # Only react to true write events; skip access / close / open
etc.
+ if not isinstance(
+ event, (FileCreatedEvent, FileModifiedEvent,
FileMovedEvent)
+ ):
+ return
- # Touch superset/__init__.py to trigger Flask's file watcher
- superset_init = Path("superset/__init__.py")
- logger.info("Triggering restart by touching %s", superset_init)
- os.utime(superset_init, (time.time(), time.time()))
+ if isinstance(event, FileMovedEvent):
+ self._handle_moved(event)
+ return
+
+ # For Create/Modify events watchdog only sets src_path.
+ target = getattr(event, "src_path", None)
+ if not isinstance(target, str):
+ return
+
+ # Only care about paths inside a `dist` directory.
+ if "dist" not in Path(target).parts:
+ return
+
+ # For Create/Modify, verify the content actually changed to
+ # ignore spurious inotify events generated by Docker bind-mount
+ # reads.
+ if not self._content_changed(target):
+ return
+
+ self._schedule_reload(target)
return LocalExtensionFileHandler
except ImportError:
@@ -130,11 +299,23 @@ def setup_local_extensions_watcher(app: Flask) -> None:
# noqa: C901
if not watch_dirs:
return
+ # Ensure the sentinel exists so touch() and Flask's --extra-files watcher
+ # both have a real path to operate on.
+ try:
+ RELOAD_TRIGGER.touch(exist_ok=True)
+ except OSError as e:
+ logger.warning("Could not create reload trigger %s: %s",
RELOAD_TRIGGER, e)
+ return
+
try:
from watchdog.observers import Observer
# Set up and start the file watcher
event_handler = handler_class()
+ # Pre-populate baseline hashes from existing dist files so the
+ # developer's first real edit isn't silently dropped as a "first
+ # observation".
+ event_handler.prime_baseline(watch_dirs)
observer = Observer()
for watch_dir in watch_dirs:
diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py
index fc5b6250bb6..82f7b475d7f 100644
--- a/superset/extensions/utils.py
+++ b/superset/extensions/utils.py
@@ -35,12 +35,17 @@ from superset.utils.core import check_is_safe_zip
logger = logging.getLogger(__name__)
-FRONTEND_REGEX = re.compile(r"^frontend/dist/([^/]+)$")
+# Accept nested paths inside frontend/dist so extensions can serve
+# worker / WASM / chunk subfolders. Reject any entry whose path contains "..",
+# conservatively excluding parent traversal segments so a crafted entry name
+# cannot escape the bundle directory (defense in depth; check_is_safe_zip runs
+# first).
+FRONTEND_REGEX: re.Pattern[str] = re.compile(r"^frontend/dist/(?!.*\.\.)(.+)$")
# Reject any entry whose path contains "..", conservatively excluding parent
# traversal segments along with the (in practice nonexistent) case of a module
# path embedding consecutive dots, so a crafted entry name cannot produce a
# traversal-style module path (defense in depth; check_is_safe_zip runs first).
-BACKEND_REGEX = re.compile(r"^backend/src/(?!.*\.\.)(.+)$")
+BACKEND_REGEX: re.Pattern[str] = re.compile(r"^backend/src/(?!.*\.\.)(.+)$")
class InMemoryLoader(importlib.abc.Loader):
diff --git a/tests/unit_tests/extensions/test_cache_middleware.py
b/tests/unit_tests/extensions/test_cache_middleware.py
index 22f8b506820..e9398032d68 100644
--- a/tests/unit_tests/extensions/test_cache_middleware.py
+++ b/tests/unit_tests/extensions/test_cache_middleware.py
@@ -64,6 +64,21 @@ def test_asset_path_is_intercepted() -> None:
assert "Cookie" not in vary
+def test_nested_asset_path_is_intercepted() -> None:
+ headers: ResponseHeaders = call_middleware(
+ "/api/v1/extensions/acme/my-ext/workers/nested/chunk.wasm",
+ [("Vary", "Accept-Encoding, Cookie")],
+ )
+ vary: str = dict(headers).get("Vary", "")
+ assert "Cookie" not in vary
+
+
+def test_get_endpoint_with_trailing_slash_is_not_intercepted() -> None:
+ upstream: ResponseHeaders = [("Vary", "Accept-Encoding, Cookie")]
+ headers = call_middleware("/api/v1/extensions/acme/my-ext/", upstream)
+ assert headers == upstream
+
+
def test_list_endpoint_is_not_intercepted() -> None:
upstream = [("Vary", "Accept-Encoding, Cookie")]
headers = call_middleware("/api/v1/extensions/", upstream)
diff --git a/tests/unit_tests/extensions/test_local_extensions_watcher.py
b/tests/unit_tests/extensions/test_local_extensions_watcher.py
new file mode 100644
index 00000000000..85f2a2bdaac
--- /dev/null
+++ b/tests/unit_tests/extensions/test_local_extensions_watcher.py
@@ -0,0 +1,59 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from typing import Any
+
+from watchdog.events import FileDeletedEvent, FileMovedEvent
+
+from superset.extensions.local_extensions_watcher import
_get_file_handler_class
+
+
+def _noop_schedule_reload(_path: str) -> None:
+ """Stand in for a real debounce timer in unit tests."""
+
+
+def make_handler() -> Any:
+ handler_class = _get_file_handler_class()
+ handler = handler_class()
+ # Avoid spinning up real debounce timers in unit tests.
+ handler._schedule_reload = _noop_schedule_reload
+ return handler
+
+
+def test_delete_evicts_hash_entry() -> None:
+ handler = make_handler()
+ handler._file_hashes["/ext/dist/old-chunk.abc123.js"] = "digest"
+
+ handler.on_any_event(FileDeletedEvent("/ext/dist/old-chunk.abc123.js"))
+
+ assert "/ext/dist/old-chunk.abc123.js" not in handler._file_hashes
+
+
+def test_delete_of_untracked_path_is_a_noop() -> None:
+ handler = make_handler()
+
+ handler.on_any_event(FileDeletedEvent("/ext/dist/never-seen.js"))
+
+ assert handler._file_hashes == {}
+
+
+def test_move_out_of_dist_evicts_source_hash_entry() -> None:
+ handler = make_handler()
+ handler._file_hashes["/ext/dist/chunk.js"] = "digest"
+
+ handler.on_any_event(FileMovedEvent("/ext/dist/chunk.js",
"/ext/tmp/chunk.js"))
+
+ assert "/ext/dist/chunk.js" not in handler._file_hashes