sparkforensics-operator 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ from sparkforensics_operator.callback import spark_forensics_callback
2
+ from sparkforensics_operator.exceptions import ThresholdBreached
3
+ from sparkforensics_operator.hooks.analyze.base import AnalyzeHook
4
+ from sparkforensics_operator.hooks.analyze.subprocess import SubprocessAnalyzeHook
5
+ from sparkforensics_operator.hooks.log_source.base import LogSourceHook
6
+ from sparkforensics_operator.hooks.log_source.filesystem import FilesystemLogSourceHook
7
+ from sparkforensics_operator.hooks.log_source.history_server import HistoryServerLogSourceHook
8
+ from sparkforensics_operator.hooks.log_source.sftp import SFTPLogSourceHook
9
+ from sparkforensics_operator.hooks.log_source.tunnel import SSHTunneledLogSourceHook
10
+ from sparkforensics_operator.hooks.log_source.xcom import XComLogSourceHook
11
+ from sparkforensics_operator.notify import Notifier
12
+ from sparkforensics_operator.operator import SparkForensicsOperator
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ __all__ = [
17
+ "SparkForensicsOperator",
18
+ "spark_forensics_callback",
19
+ "ThresholdBreached",
20
+ "LogSourceHook",
21
+ "HistoryServerLogSourceHook",
22
+ "FilesystemLogSourceHook",
23
+ "XComLogSourceHook",
24
+ "SFTPLogSourceHook",
25
+ "SSHTunneledLogSourceHook",
26
+ "AnalyzeHook",
27
+ "SubprocessAnalyzeHook",
28
+ "Notifier",
29
+ ]
@@ -0,0 +1,23 @@
1
+ """
2
+ Isolates the one real Airflow-2-vs-3 import difference this package depends
3
+ on. Airflow 3's Task SDK moved BaseOperator/BaseOperatorLink to the
4
+ airflow.sdk namespace; airflow.sdk does not exist on Airflow 2.x. Everywhere
5
+ else in this package, import BaseOperator/BaseOperatorLink from here, never
6
+ straight from airflow.
7
+
8
+ Within the Airflow 2.x branch there is a second split: BaseOperatorLink
9
+ moved out of airflow.models.baseoperator and into its own
10
+ airflow.models.baseoperatorlink module in Airflow 2.8.0. On Airflow
11
+ 2.6.0/2.7.0, airflow.models.baseoperatorlink does not exist yet and
12
+ BaseOperatorLink must still be imported from airflow.models.baseoperator.
13
+ """
14
+ try:
15
+ from airflow.sdk import BaseOperator, BaseOperatorLink
16
+ except ImportError: # Airflow 2.x has no airflow.sdk package
17
+ from airflow.models.baseoperator import BaseOperator
18
+ try:
19
+ from airflow.models.baseoperatorlink import BaseOperatorLink # Airflow >= 2.8
20
+ except ImportError: # Airflow 2.6/2.7
21
+ from airflow.models.baseoperator import BaseOperatorLink
22
+
23
+ __all__ = ["BaseOperator", "BaseOperatorLink"]
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Callable
5
+
6
+ from sparkforensics_operator.exceptions import ThresholdBreached
7
+ from sparkforensics_operator.operator import run_spark_forensics
8
+
9
+ log = logging.getLogger("airflow.task")
10
+
11
+
12
+ def spark_forensics_callback(
13
+ *,
14
+ log_source,
15
+ backend,
16
+ report_dest: str,
17
+ max_runtime_ms: int | None = None,
18
+ max_spill_gb: float | None = None,
19
+ max_skew_ratio: float | None = None,
20
+ max_failed_task_rate_pct: float | None = None,
21
+ min_efficiency_pct: float | None = None,
22
+ on_threshold_breach: str = "fail",
23
+ notifier=None,
24
+ aws_conn_id: str | None = None,
25
+ ) -> Callable[[dict], None]:
26
+ """Builds an on_success_callback for the upstream Spark task, sharing
27
+ SparkForensicsOperator's exact execute() logic via run_spark_forensics.
28
+
29
+ Note (see the plan's "Global constraints"): Airflow itself catches and
30
+ only logs any exception this callback raises, it never retries and
31
+ never fails the upstream task. Set on_threshold_breach="fail" here only
32
+ to get that log-and-continue behavior on breach; it will not fail the
33
+ DAG the way the standalone-operator trigger shape does.
34
+ """
35
+ thresholds = {
36
+ "max_runtime_ms": max_runtime_ms,
37
+ "max_spill_gb": max_spill_gb,
38
+ "max_skew_ratio": max_skew_ratio,
39
+ "max_failed_task_rate_pct": max_failed_task_rate_pct,
40
+ "min_efficiency_pct": min_efficiency_pct,
41
+ }
42
+
43
+ def _callback(context: dict) -> None:
44
+ ti = context.get("ti")
45
+ try:
46
+ destination = run_spark_forensics(
47
+ context,
48
+ log_source=log_source,
49
+ backend=backend,
50
+ report_dest=report_dest,
51
+ thresholds=thresholds,
52
+ on_threshold_breach=on_threshold_breach,
53
+ notifier=notifier,
54
+ log=log,
55
+ aws_conn_id=aws_conn_id,
56
+ )
57
+ except ThresholdBreached as e:
58
+ # on_threshold_breach="fail" (the default) makes run_spark_forensics
59
+ # raise ThresholdBreached *after* the report has already been
60
+ # persisted successfully. Without this, the callback path would
61
+ # never record the report's location on the one path (a breach)
62
+ # where a user most wants the report link. run_spark_forensics
63
+ # attaches the already-known-good destination to the exception
64
+ # for exactly this reason (see operator.py); still push it to
65
+ # XCom before letting the breach propagate as usual.
66
+ destination = getattr(e, "destination", None)
67
+ if ti and destination is not None:
68
+ ti.xcom_push(key="return_value", value=destination)
69
+ raise
70
+
71
+ # No operator auto-pushes this callback's return value to XCom, so
72
+ # push it manually under the same key ("return_value") an operator's
73
+ # own return value would use (see links.py's _XCOM_RETURN_KEY),
74
+ # keeping the persisted report's location discoverable via
75
+ # ti.xcom_pull() on the upstream task. Guard against a malformed/test
76
+ # context missing "ti": this is a notification-adjacent side effect
77
+ # and must not turn an already-successful run into a new failure.
78
+ if ti:
79
+ ti.xcom_push(key="return_value", value=destination)
80
+
81
+ return _callback
@@ -0,0 +1,10 @@
1
+ from airflow.exceptions import AirflowFailException
2
+
3
+
4
+ class ThresholdBreached(AirflowFailException):
5
+ """Raised when a configured SparkForensics threshold is violated and
6
+ on_threshold_breach="fail". Extends AirflowFailException (not
7
+ AirflowException): a breach is a deterministic verdict from the
8
+ already-completed analysis, so retrying would just re-run the same
9
+ fetch+analyze cycle to reach the same answer, and should not consume
10
+ the task's configured retries."""
File without changes
File without changes
@@ -0,0 +1,18 @@
1
+ from abc import ABC, abstractmethod
2
+ from pathlib import Path
3
+
4
+ from airflow.hooks.base import BaseHook
5
+
6
+ from sparkforensics_operator.report import Report
7
+
8
+
9
+ class AnalyzeHook(BaseHook, ABC):
10
+ """Runs sparkforensics analysis over a local event log and returns a
11
+ Report. v1 ships one implementation (subprocess against the
12
+ sparkforensics-analyze CLI), see the plan's "Future: http/MCP
13
+ AnalyzeHook backend" section for why an HTTP backend was deferred."""
14
+
15
+ @abstractmethod
16
+ def analyze(self, log_path: Path, thresholds: dict) -> Report:
17
+ """thresholds keys: max_runtime_ms, max_spill_gb, max_skew_ratio,
18
+ max_failed_task_rate_pct, min_efficiency_pct (all optional)."""
@@ -0,0 +1,72 @@
1
+ import subprocess
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ from airflow.exceptions import AirflowException
6
+
7
+ from sparkforensics_operator.report import THRESHOLD_CLI_FLAGS, Report, parse_report_json, parse_threshold_results
8
+
9
+ from .base import AnalyzeHook
10
+
11
+ # Exit codes 0 (success), 1 (threshold violated), and 3 (thresholds inconclusive) per the CLI's documented exit-code contract; anything else (OOM-kill, wrapper failure, etc.) means --out file is not trustworthy.
12
+ _USABLE_OUT_FILE_EXIT_CODES = {0, 1, 3}
13
+
14
+
15
+ class SubprocessAnalyzeHook(AnalyzeHook):
16
+ """Shells out to `sparkforensics-analyze <log_path> --format json --out
17
+ <tmpfile> [threshold flags]`. Requires Node.js (engines
18
+ '>=22.18.0 <23.0.0 || >=23.6.0') and the sparkforensics npm package
19
+ installed on the worker, with sparkforensics-analyze resolvable on
20
+ PATH (or pass analyze_bin=<full path>)."""
21
+
22
+ def __init__(self, analyze_bin: str = "sparkforensics-analyze", timeout: int = 900):
23
+ super().__init__()
24
+ self.analyze_bin = analyze_bin
25
+ self.timeout = timeout
26
+
27
+ def _threshold_args(self, thresholds: dict) -> list:
28
+ args = []
29
+ for key, flag in THRESHOLD_CLI_FLAGS.items():
30
+ value = thresholds.get(key)
31
+ if value is not None:
32
+ args.extend([flag, str(value)])
33
+ return args
34
+
35
+ def analyze(self, log_path: Path, thresholds: dict) -> Report:
36
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as out_file:
37
+ out_path = Path(out_file.name)
38
+
39
+ args = [self.analyze_bin, str(log_path), "--format", "json", "--out", str(out_path)]
40
+ args.extend(self._threshold_args(thresholds))
41
+
42
+ try:
43
+ try:
44
+ proc = subprocess.run(args, capture_output=True, text=True, timeout=self.timeout)
45
+ except FileNotFoundError as e:
46
+ raise AirflowException(
47
+ f"sparkforensics-analyze binary not found: {self.analyze_bin!r}. "
48
+ "Install the sparkforensics npm package on this worker, or pass "
49
+ "analyze_bin=<full path to sparkforensics-analyze>."
50
+ ) from e
51
+ except subprocess.TimeoutExpired as e:
52
+ raise AirflowException(
53
+ f"sparkforensics-analyze timed out after {self.timeout}s analyzing {log_path}."
54
+ ) from e
55
+
56
+ if proc.returncode == 2:
57
+ raise AirflowException(
58
+ f"sparkforensics-analyze failed to parse the event log or was given "
59
+ f"bad arguments (exit 2): {proc.stderr.strip()}"
60
+ )
61
+ if proc.returncode not in _USABLE_OUT_FILE_EXIT_CODES:
62
+ raise AirflowException(
63
+ f"sparkforensics-analyze exited with unexpected code {proc.returncode}: "
64
+ f"{proc.stderr.strip()}"
65
+ )
66
+
67
+ report = parse_report_json(out_path)
68
+ report.threshold_results = parse_threshold_results(thresholds, proc.stderr)
69
+ report.exit_code = proc.returncode
70
+ return report
71
+ finally:
72
+ out_path.unlink(missing_ok=True)
File without changes
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+
8
+ def dest_for(dest_root: Path, source_path: str | Path) -> Path:
9
+ """The dest_root/<basename> convention shared by every LogSourceHook that
10
+ stages a single file: FilesystemLogSourceHook (local copy) and
11
+ SFTPLogSourceHook (remote-to-local fetch)."""
12
+ return dest_root / Path(source_path).name
13
+
14
+
15
+ def make_dest_root(dest_dir: str | None) -> tuple[Path, Path | None]:
16
+ """Resolves the directory a hook stages files into: dest_dir if the
17
+ caller gave one (a shared, caller-managed directory — the second tuple
18
+ element is None so cleanup() never touches it), else a fresh private
19
+ temp dir this hook owns (returned as both elements, so the caller can
20
+ rmtree it later, on error or in cleanup())."""
21
+ if dest_dir is not None:
22
+ root = Path(dest_dir)
23
+ return root, None
24
+ owned = Path(tempfile.mkdtemp(prefix="sparkforensics-"))
25
+ return owned, owned
26
+
27
+
28
+ def remove_if_owned(owned_temp_root: Path | None) -> None:
29
+ if owned_temp_root is not None:
30
+ shutil.rmtree(owned_temp_root, ignore_errors=True)
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def _safe_path_component(value: object) -> str | None:
7
+ """Reduce an attacker-influenceable context string (e.g. run_id, which is
8
+ settable via Airflow's trigger API) to its filesystem basename before it
9
+ is interpolated into path_template, the same way history_server.py
10
+ sanitizes app_id. None is preserved as None rather than becoming the
11
+ literal string "None"."""
12
+ if value is None:
13
+ return None
14
+ return Path(str(value)).name
15
+
16
+
17
+ def _template_vars(context: dict) -> dict:
18
+ dag = context.get("dag")
19
+ task = context.get("task")
20
+ return {
21
+ "ds": _safe_path_component(context.get("ds")),
22
+ "run_id": _safe_path_component(context.get("run_id")),
23
+ # Not sanitized: logical_date is datetime-like and path_template may
24
+ # apply a strftime format spec to it (e.g. "{logical_date:%Y-%m-%d}");
25
+ # Path(...).name would break that formatting and, unlike a plain
26
+ # string substitution, .format()'s format-spec mini-language for a
27
+ # datetime doesn't accept arbitrary attacker strings.
28
+ "logical_date": context.get("logical_date"),
29
+ "dag_id": _safe_path_component(dag.dag_id) if dag is not None else None,
30
+ "task_id": _safe_path_component(task.task_id) if task is not None else None,
31
+ }
@@ -0,0 +1,4 @@
1
+ import re
2
+
3
+ # Mirrors isRollingLogDirectory in sparkforensics's src/cli/collect-run.ts.
4
+ _ROLLING_ENTRY_RE = re.compile(r"^events_\d+_")
@@ -0,0 +1,24 @@
1
+ from abc import ABC, abstractmethod
2
+ from pathlib import Path
3
+
4
+ from airflow.hooks.base import BaseHook
5
+
6
+
7
+ class LogSourceHook(BaseHook, ABC):
8
+ """Fetches a Spark job's event log to a local path. Implementations
9
+ differ only in where the log comes from: a Spark History Server, a
10
+ filesystem/HDFS path pattern, or an XCom value pushed by the upstream
11
+ Spark task.
12
+ """
13
+
14
+ @abstractmethod
15
+ def fetch(self, context: dict) -> Path:
16
+ """Return a local path to the event log: either a single file, or a
17
+ directory of rolling-log segments (files named events_<n>_...)."""
18
+
19
+ def cleanup(self, path: Path) -> None:
20
+ """Called after analysis, on success or failure. No-op by default:
21
+ override only to remove a path this hook created for itself (e.g. a
22
+ private temp dir); never one it merely handed back to the caller,
23
+ such as a user-owned filesystem path or another task's
24
+ XCom-referenced file."""
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ from airflow.exceptions import AirflowException
7
+
8
+ from ._dest_root import dest_for
9
+ from ._path_template import _template_vars
10
+ from .base import LogSourceHook
11
+
12
+
13
+ class FilesystemLogSourceHook(LogSourceHook):
14
+ """Reads an event log from a configured filesystem/HDFS path pattern.
15
+ "HDFS" here means an already-mounted path (NFS gateway, WebHDFS FUSE
16
+ mount, or similar), this hook does no Hadoop-client I/O of its own, it
17
+ just reads/copies a local path, the same way FilesystemLogSourceHook's
18
+ output is handed straight to sparkforensics-analyze's own local-file
19
+ reader."""
20
+
21
+ def __init__(self, path_template: str, dest_dir: str | None = None):
22
+ super().__init__()
23
+ self.path_template = path_template
24
+ self.dest_dir = dest_dir
25
+
26
+ def fetch(self, context: dict) -> Path:
27
+ rendered = self.path_template.format(**_template_vars(context))
28
+ source = Path(rendered)
29
+ if not source.exists():
30
+ raise AirflowException(f"Configured log path does not exist: {source}")
31
+ if self.dest_dir is None:
32
+ return source
33
+
34
+ dest_root = Path(self.dest_dir)
35
+ dest_root.mkdir(parents=True, exist_ok=True)
36
+ dest = dest_for(dest_root, source)
37
+ if source.is_dir():
38
+ shutil.copytree(source, dest, dirs_exist_ok=True)
39
+ else:
40
+ shutil.copy2(source, dest)
41
+ return dest
42
+
43
+ def cleanup(self, path: Path) -> None:
44
+ if self.dest_dir is None:
45
+ return # fetch() returned the caller's own path; never delete it.
46
+ if path.is_dir():
47
+ shutil.rmtree(path, ignore_errors=True)
48
+ else:
49
+ path.unlink(missing_ok=True)
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import zipfile
5
+ from pathlib import Path
6
+ from urllib.parse import quote
7
+
8
+ import requests
9
+ from airflow.exceptions import AirflowException
10
+
11
+ from ._dest_root import make_dest_root, remove_if_owned
12
+ from ._rolling_log import _ROLLING_ENTRY_RE
13
+ from .base import LogSourceHook
14
+
15
+
16
+ class HistoryServerLogSourceHook(LogSourceHook):
17
+ """Downloads a Spark job's event log from a Spark History Server's REST
18
+ API: GET {base_url}/api/v1/applications/{app_id}[/{attempt_id}]/logs,
19
+ which always returns a zip (one entry for a single event-log file,
20
+ multiple events_<n>_... entries for a rolling log)."""
21
+
22
+ def __init__(
23
+ self,
24
+ base_url: str,
25
+ app_id: str,
26
+ attempt_id: str | None = None,
27
+ dest_dir: str | None = None,
28
+ timeout: int = 300,
29
+ ):
30
+ super().__init__()
31
+ self.base_url = base_url.rstrip("/")
32
+ self.app_id = app_id
33
+ self.attempt_id = attempt_id
34
+ self.dest_dir = dest_dir
35
+ self.timeout = timeout
36
+ self._owned_temp_root: Path | None = None
37
+
38
+ def _build_url(self) -> str:
39
+ segments = ["api", "v1", "applications", quote(self.app_id, safe="")]
40
+ if self.attempt_id:
41
+ segments.append(quote(self.attempt_id, safe=""))
42
+ segments.append("logs")
43
+ return f"{self.base_url}/{'/'.join(segments)}"
44
+
45
+ def fetch(self, context: dict) -> Path:
46
+ url = self._build_url()
47
+ response = requests.get(url, timeout=self.timeout, stream=True)
48
+ if response.status_code != 200:
49
+ raise AirflowException(
50
+ f"Spark History Server log download failed ({response.status_code}) for {url}"
51
+ )
52
+
53
+ dest_root, self._owned_temp_root = make_dest_root(self.dest_dir)
54
+
55
+ try:
56
+ dest_root.mkdir(parents=True, exist_ok=True)
57
+
58
+ # self.app_id is only safe as a URL segment (quoted above); sanitize
59
+ # it before reusing it as a filesystem path component so a
60
+ # crafted/unexpected app_id (e.g. containing "../") can't escape
61
+ # dest_root.
62
+ safe_app_id = Path(self.app_id).name
63
+ zip_path = dest_root / f"{safe_app_id}.zip"
64
+ # self.timeout only bounds each individual socket read/connect (see
65
+ # the requests.get call above); a slow-trickling connection could
66
+ # otherwise keep the download running far longer than self.timeout
67
+ # implies. Track an explicit wall-clock deadline so self.timeout
68
+ # bounds the entire transfer, not just each chunk read.
69
+ deadline = time.monotonic() + self.timeout
70
+ with open(zip_path, "wb") as fh:
71
+ for chunk in response.iter_content(chunk_size=1024 * 1024):
72
+ fh.write(chunk)
73
+ if time.monotonic() > deadline:
74
+ response.close()
75
+ raise AirflowException(
76
+ f"Spark History Server log download for app {self.app_id} "
77
+ f"exceeded {self.timeout}s (total transfer time, not just a "
78
+ "single read/connect)."
79
+ )
80
+
81
+ extract_dir = dest_root / safe_app_id
82
+ with zipfile.ZipFile(zip_path) as zf:
83
+ names = zf.namelist()
84
+ zf.extractall(extract_dir)
85
+ zip_path.unlink()
86
+
87
+ if len(names) == 1:
88
+ return extract_dir / names[0]
89
+ if any(_ROLLING_ENTRY_RE.match(Path(name).name) for name in names):
90
+ return extract_dir
91
+ raise AirflowException(
92
+ f"Unexpected Spark History Server log archive contents for app {self.app_id}: {names}"
93
+ )
94
+ except Exception:
95
+ remove_if_owned(self._owned_temp_root)
96
+ raise
97
+
98
+ def cleanup(self, path: Path) -> None:
99
+ remove_if_owned(self._owned_temp_root)
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import concurrent.futures
4
+ from pathlib import Path
5
+
6
+ from airflow.exceptions import AirflowException
7
+
8
+ from ._dest_root import dest_for, make_dest_root, remove_if_owned
9
+ from ._path_template import _template_vars
10
+ from ._rolling_log import _ROLLING_ENTRY_RE
11
+ from .base import LogSourceHook
12
+
13
+
14
+ class SFTPLogSourceHook(LogSourceHook):
15
+ """Reads an event log from an on-prem filesystem/HDFS path pattern the
16
+ cloud Airflow worker can't mount directly, over the same SSH connection
17
+ already configured for SSHOperator (via SFTPHook). The SSH-reachable
18
+ equivalent of FilesystemLogSourceHook: unlike that hook, every fetch is
19
+ a remote-to-local copy. Same cleanup() convention as
20
+ HistoryServerLogSourceHook (not FilesystemLogSourceHook): the path is
21
+ auto-cleaned when dest_dir is None (a private temp dir this hook
22
+ owns), but is a no-op when dest_dir is set (a caller-managed shared
23
+ directory)."""
24
+
25
+ def __init__(self, ssh_conn_id: str, path_template: str, dest_dir: str | None = None):
26
+ super().__init__()
27
+ self.ssh_conn_id = ssh_conn_id
28
+ self.path_template = path_template
29
+ self.dest_dir = dest_dir
30
+ self._owned_temp_root: Path | None = None
31
+
32
+ def fetch(self, context: dict) -> Path:
33
+ from airflow.providers.sftp.hooks.sftp import SFTPHook
34
+
35
+ remote_path = self.path_template.format(**_template_vars(context))
36
+ dest_root, self._owned_temp_root = make_dest_root(self.dest_dir)
37
+
38
+ try:
39
+ sftp_hook = SFTPHook(ssh_conn_id=self.ssh_conn_id)
40
+ if not sftp_hook.path_exists(remote_path):
41
+ raise AirflowException(
42
+ f"Configured log path does not exist (checked over SFTP via "
43
+ f"ssh_conn_id={self.ssh_conn_id!r}): {remote_path}"
44
+ )
45
+
46
+ dest_root.mkdir(parents=True, exist_ok=True)
47
+
48
+ if sftp_hook.isdir(remote_path):
49
+ entries = sftp_hook.list_directory(remote_path) or []
50
+ rolling_entries = [e for e in entries if _ROLLING_ENTRY_RE.match(Path(e).name)]
51
+ if not rolling_entries:
52
+ raise AirflowException(
53
+ f"Unexpected SFTP log directory contents (not a rolling-log "
54
+ f"layout) at {remote_path!r} (checked over SFTP via "
55
+ f"ssh_conn_id={self.ssh_conn_id!r}): {entries}"
56
+ )
57
+ source_name = Path(remote_path).name
58
+ if not source_name:
59
+ raise AirflowException(
60
+ f"path_template resolved to a remote directory with no name "
61
+ f"component to stage locally under dest_dir: {remote_path!r}"
62
+ )
63
+ # Isolate per-source, same convention as FilesystemLogSourceHook's
64
+ # dest_root / source.name: a caller-supplied dest_dir shared across
65
+ # runs must not mix one run's events_<n>_* segments with another's.
66
+ source_dir = dest_root / source_name
67
+ source_dir.mkdir(parents=True, exist_ok=True)
68
+
69
+ def _download(entry: str) -> None:
70
+ # Re-derive the basename that already passed the
71
+ # _ROLLING_ENTRY_RE check above and use it (not the raw
72
+ # entry) for the local destination, so a malformed
73
+ # directory entry can't escape source_dir.
74
+ local_name = Path(entry).name
75
+ remote_file = f"{remote_path.rstrip('/')}/{entry}"
76
+ sftp_hook.retrieve_file(remote_file, str(source_dir / local_name))
77
+
78
+ # Segments are independent files over the same SSH connection;
79
+ # overlapping the transfers cuts wall-clock time for
80
+ # multi-segment rolling logs versus one-at-a-time downloads.
81
+ with concurrent.futures.ThreadPoolExecutor(
82
+ max_workers=min(8, len(rolling_entries))
83
+ ) as pool:
84
+ list(pool.map(_download, rolling_entries))
85
+ return source_dir
86
+
87
+ source_name = Path(remote_path).name
88
+ if not source_name:
89
+ raise AirflowException(
90
+ f"path_template resolved to a remote path with no filename "
91
+ f"component to stage locally under dest_dir: {remote_path!r}"
92
+ )
93
+ local_path = dest_for(dest_root, remote_path)
94
+ sftp_hook.retrieve_file(remote_path, str(local_path))
95
+ return local_path
96
+ except AirflowException:
97
+ remove_if_owned(self._owned_temp_root)
98
+ raise
99
+ except Exception as e:
100
+ remove_if_owned(self._owned_temp_root)
101
+ raise AirflowException(
102
+ f"SFTP log fetch failed (ssh_conn_id={self.ssh_conn_id!r}, "
103
+ f"remote_path={remote_path!r}): {e}"
104
+ ) from e
105
+
106
+ def cleanup(self, path: Path) -> None:
107
+ remove_if_owned(self._owned_temp_root)