diff --git a/docker-compose.yml b/docker-compose.yml index bd474a83ef4..583c1b13cc8 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 d20f28007f1..12375bec92e 100755 --- a/docker/docker-bootstrap.sh +++ b/docker/docker-bootstrap.sh @@ -80,7 +80,9 @@ case "${1}" in ;; app) echo "Starting web app (using development server)..." - flask run -p $PORT --reload --debugger --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*" + flask run -p $PORT --reload --debugger --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 fc386d60bf2..fbaa490cb1e 100644 --- a/superset/extensions/api.py +++ b/superset/extensions/api.py @@ -169,7 +169,7 @@ class ExtensionsRestApi(BaseApi): @protect() @safe - @expose("///", methods=("GET",)) + @expose("///", methods=("GET",)) def content(self, publisher: str, name: str, file: str) -> Response: """Get a frontend chunk of an extension. --- diff --git a/superset/extensions/local_extensions_watcher.py b/superset/extensions/local_extensions_watcher.py index 6233f91fe5c..4a6e3ad8f66 100644 --- a/superset/extensions/local_extensions_watcher.py +++ b/superset/extensions/local_extensions_watcher.py @@ -29,37 +29,113 @@ 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(__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, + 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 + self._file_hashes: dict[str, str] = {} + # Deduplicate: only trigger once per second across all files + self._last_trigger: float = 0.0 + self._lock = threading.Lock() + + # ── 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 _content_changed(self, path: str) -> bool: + """Return True only when the file's content differs from last seen. + + The first time a path is observed its hash is stored as the baseline + and False is returned — that event is a 'first-seen', not a change. + Only a subsequent event where the digest differs from the baseline + is treated as a genuine content change. + """ + digest = self._sha256(path) + if digest is None: + return False + old_digest = self._file_hashes.get(path) + self._file_hashes[path] = digest + if old_digest is None: + # First observation — record baseline, do not trigger restart. + return False + return old_digest != digest + + # ── 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 + # Only react to true write events; skip access / close / open etc. + if not isinstance( + event, (FileCreatedEvent, FileModifiedEvent, FileMovedEvent) + ): + return + + # Only care about files inside a `dist` directory src = getattr(event, "src_path", None) if not isinstance(src, str) or "dist" not in Path(src).parts: return + # Verify the file content actually changed to ignore spurious + # inotify events generated by Docker bind-mount reads. + if not self._content_changed(src): + return + + # Debounce: one restart per second max, regardless of how many + # files webpack writes simultaneously. + now = time.monotonic() + with self._lock: + if now - self._last_trigger < 1.0: + return + self._last_trigger = now + logger.info( "File change detected in LOCAL_EXTENSIONS: %s", event.src_path ) - # 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())) + # Touch the dedicated reload-trigger sentinel file. + # Flask watches this via --extra-files; it is never read by Python + # so Docker VirtioFS will not generate spurious inotify events on it. + logger.info("Triggering restart by touching %s", RELOAD_TRIGGER) + os.utime(RELOAD_TRIGGER, (time.time(), time.time())) return LocalExtensionFileHandler except ImportError: @@ -130,6 +206,14 @@ def setup_local_extensions_watcher(app: Flask) -> None: # noqa: C901 if not watch_dirs: return + # Ensure the sentinel exists so os.utime() 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 diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py index 6332c97435d..29814d2eb1a 100644 --- a/superset/extensions/utils.py +++ b/superset/extensions/utils.py @@ -35,7 +35,7 @@ from superset.utils.core import check_is_safe_zip logger = logging.getLogger(__name__) -FRONTEND_REGEX = re.compile(r"^frontend/dist/([^/]+)$") +FRONTEND_REGEX = re.compile(r"^frontend/dist/(.+)$") BACKEND_REGEX = re.compile(r"^backend/src/(.+)$")