Files
mlflow--mlflow/mlflow/pyfunc/dbconnect_artifact_cache.py
2026-07-13 13:22:34 +08:00

166 lines
6.5 KiB
Python

import json
import os
import subprocess
import tarfile
from pathlib import Path
from mlflow.exceptions import MlflowException
from mlflow.utils.databricks_utils import is_in_databricks_runtime
from mlflow.utils.file_utils import check_tarfile_security, get_or_create_tmp_dir
_CACHE_MAP_FILE_NAME = "db_connect_artifact_cache.json"
class DBConnectArtifactCache:
"""
Manages Databricks Connect artifacts cache.
Note it doesn't support OSS Spark Connect.
This class can be used in the following environment:
- Databricks shared cluster python notebook REPL
- Databricks Serverless python notebook REPL
- Databricks connect client python REPL that connects to remote Databricks Serverless
- Databricks connect client python REPL that connects to remote Databricks shared cluster
.. code-block:: python
:caption: Example
# client side code
db_artifact_cache = DBConnectArtifactCache.get_or_create()
db_artifact_cache.add_artifact_archive("archive1", "/tmp/archive1.tar.gz")
@pandas_udf(...)
def my_udf(x):
# we can get the unpacked archive files in `archive1_unpacked_dir`
archive1_unpacked_dir = db_artifact_cache.get("archive1")
"""
_global_cache = None
@staticmethod
def get_or_create(spark):
if (
DBConnectArtifactCache._global_cache is None
or spark is not DBConnectArtifactCache._global_cache._spark
):
DBConnectArtifactCache._global_cache = DBConnectArtifactCache(spark)
cache_file = os.path.join(get_or_create_tmp_dir(), _CACHE_MAP_FILE_NAME)
if is_in_databricks_runtime() and os.path.exists(cache_file):
# In databricks runtime (shared cluster or Serverless), when you restart the
# notebook REPL by %restart_python or dbutils.library.restartPython(), the
# DBConnect session is still preserved. So in this case, we can reuse the cached
# artifact files.
# So that when adding artifact, the cache map is serialized to local disk file
# `db_connect_artifact_cache.json` and after REPL restarts,
# `DBConnectArtifactCache` restores the cache map by loading data from the file.
with open(cache_file) as f:
DBConnectArtifactCache._global_cache._cache = json.load(f)
return DBConnectArtifactCache._global_cache
def __init__(self, spark):
self._spark = spark
self._cache = {}
def __getstate__(self):
"""
The `DBConnectArtifactCache` instance is created in Databricks Connect client side,
and it will be pickled to Databricks Connect UDF sandbox
(see `get_unpacked_artifact_dir` method), but Spark Connect client object is
not pickle-able, we need to skip this field.
"""
state = self.__dict__.copy()
# Don't pickle `_spark`
del state["_spark"]
return state
def __setstate__(self, state):
self.__dict__.update(state)
self._spark = None
def has_cache_key(self, cache_key):
return cache_key in self._cache
def add_artifact_archive(self, cache_key, artifact_archive_path):
"""
Add an artifact archive file to Databricks connect cache.
The archive file must be 'tar.gz' format.
You can only call this method in Databricks Connect client side.
"""
if not artifact_archive_path.endswith(".tar.gz"):
raise RuntimeError(
"'add_artifact_archive' only supports archive file in 'tar.gz' format."
)
archive_file_name = os.path.basename(artifact_archive_path)
if cache_key not in self._cache:
self._spark.addArtifact(artifact_archive_path, archive=True)
self._cache[cache_key] = archive_file_name
if is_in_databricks_runtime():
with open(os.path.join(get_or_create_tmp_dir(), _CACHE_MAP_FILE_NAME), "w") as f:
json.dump(self._cache, f)
def get_unpacked_artifact_dir(self, cache_key):
"""
Get unpacked artifact directory path, you can only call this method
inside Databricks Connect spark UDF sandbox.
"""
from pyspark.taskcontext import TaskContext
if cache_key not in self._cache:
raise RuntimeError(f"The artifact '{cache_key}' does not exist.")
archive_file_name = self._cache[cache_key]
if session_id := os.environ.get("DB_SESSION_UUID"):
task_context = TaskContext.get()
if hasattr(task_context, "artifactDir"):
return os.path.join(task_context.artifactDir(), "archives", archive_file_name)
return (
f"/local_disk0/.ephemeral_nfs/artifacts/{session_id}/archives/{archive_file_name}"
)
# If 'DB_SESSION_UUID' environment variable does not exist, it means it is running
# in a dedicated mode Spark cluster.
return os.path.join(os.getcwd(), archive_file_name)
def archive_directory(input_dir, archive_file_path):
"""
Archive the `input_dir` directory, save the archive file to `archive_file_path`,
the generated archive file is 'tar.gz' format.
Note: all symlink files in the input directory are kept as it is in the archive file.
"""
archive_file_path = os.path.abspath(archive_file_path)
# Note: `shutil.make_archive` doesn't work because it replaces symlink files with
# the file symlink pointing to, which is not the expected behavior in our usage.
# We need to pack the python and virtualenv environment, which contains a bunch of
# symlink files.
subprocess.check_call(
["tar", "-czf", archive_file_path, *os.listdir(input_dir)],
cwd=input_dir,
)
return archive_file_path
def extract_archive_to_dir(archive_path, dest_dir):
check_tarfile_security(archive_path)
os.makedirs(dest_dir, exist_ok=True)
with tarfile.open(archive_path, "r") as tar:
_safe_extractall(tar, dest_dir)
return dest_dir
def _safe_extractall(tar, dest_dir):
resolved_dest = Path(dest_dir).resolve()
for member in tar.getmembers():
member_path = (resolved_dest / member.name).resolve()
if not (member_path == resolved_dest or resolved_dest in member_path.parents):
raise MlflowException.invalid_parameter_value(
f"Tar archive member {member.name!r} would be extracted outside "
f"the destination directory."
)
tar.extract(member, path=resolved_dest)