-
Notifications
You must be signed in to change notification settings - Fork 199
feat(core): add artifact/blob store for large state values #931
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,242 @@ | ||
| # 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. | ||
|
|
||
| """Blob/object storage abstraction for large values held in :py:class:`burr.core.state.State`. | ||
|
|
||
| Rather than embedding large binary payloads (files, dataframes, images, etc...) directly in | ||
| state -- which gets fully re-serialized to the persistence layer after every action -- store the | ||
| payload in an :py:class:`ArtifactStore` and keep a small, content-addressed :py:class:`ArtifactRef` | ||
| in state instead. Deserializing a ref does *not* eagerly fetch the underlying bytes -- callers | ||
| must explicitly call :py:meth:`ArtifactRef.read` (or ``store.get_artifact``) to do so. | ||
|
|
||
| See :doc:`../reference/serde` and :doc:`../concepts/state-persistence` for background on how | ||
| state serialization interacts with this module. | ||
| """ | ||
|
|
||
| import abc | ||
| import hashlib | ||
| import os | ||
| import tempfile | ||
| from dataclasses import dataclass | ||
| from typing import Optional | ||
|
|
||
| from burr.core import serde | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class ArtifactRef: | ||
| """A lightweight, content-addressed reference to a blob stored in an :py:class:`ArtifactStore`. | ||
|
|
||
| This is what you should store in :py:class:`burr.core.state.State` -- *not* the raw bytes/object. | ||
| It is registered with :py:mod:`burr.core.serde` so it serializes/deserializes automatically as | ||
| part of normal state (de)serialization; deserializing only recreates this reference, it never | ||
| re-fetches the underlying bytes on its own. | ||
|
|
||
| :param key: The store-specific key/path the blob is stored under. | ||
| :param size_bytes: Size of the blob, in bytes. | ||
| :param digest: SHA-256 hex digest of the blob's contents, used to verify integrity on read | ||
| and to detect duplicate content. | ||
| :param media_type: Optional MIME type (e.g. ``"application/pdf"``) for the caller's own use. | ||
| """ | ||
|
|
||
| key: str | ||
| size_bytes: int | ||
| digest: str | ||
| media_type: Optional[str] = None | ||
|
|
||
| def read(self, store: "ArtifactStore", verify: bool = True) -> bytes: | ||
| """Lazily fetches the underlying bytes for this reference from ``store``. | ||
|
|
||
| :param store: The store this artifact was originally written to. | ||
| :param verify: Whether to verify the fetched bytes match ``self.digest``. Defaults to True. | ||
| :return: The raw bytes of the artifact. | ||
| """ | ||
| return store.get_artifact(self, verify=verify) | ||
|
|
||
|
|
||
| class ArtifactStore(abc.ABC): | ||
| """Base class for blob/object storage backends. | ||
|
|
||
| Implement :py:meth:`put`, :py:meth:`get`, and :py:meth:`exists` for a new backend -- | ||
| :py:meth:`put_artifact` and :py:meth:`get_artifact` are provided for free on top of those, | ||
| so digest computation/verification is not duplicated across backends. | ||
| """ | ||
|
|
||
| @abc.abstractmethod | ||
| def put(self, data: bytes, key: str) -> None: | ||
| """Stores ``data`` under ``key``. Should be idempotent -- writing the same key twice | ||
| with the same content should not error. | ||
|
|
||
| :param data: The raw bytes to store. | ||
| :param key: The key/path to store the data under. | ||
| """ | ||
|
|
||
| @abc.abstractmethod | ||
| def get(self, key: str) -> bytes: | ||
| """Retrieves the raw bytes stored under ``key``. | ||
|
|
||
| :param key: The key/path to load. | ||
| :raises FileNotFoundError: If no data is stored under ``key``. | ||
| """ | ||
|
|
||
| @abc.abstractmethod | ||
| def exists(self, key: str) -> bool: | ||
| """Returns whether ``key`` is present in the store.""" | ||
|
|
||
| def put_artifact( | ||
| self, data: bytes, media_type: Optional[str] = None, key: Optional[str] = None | ||
| ) -> ArtifactRef: | ||
| """Computes the digest/size for ``data``, writes it to the store, and returns a | ||
| fully-formed :py:class:`ArtifactRef`. This is the recommended way to write artifacts -- | ||
| it ensures every backend computes/verifies digests the same way. | ||
|
|
||
| With the default content-addressed ``key`` (the hex digest), writing identical content | ||
| twice is always a safe no-op -- the key can only match if the bytes match. | ||
|
|
||
| With an explicit ``key``, a write is only skipped if the *existing* content stored under | ||
| that key has the same digest as ``data``. If the key already exists with *different* | ||
| content, this raises -- it never silently returns a ref that doesn't describe what's | ||
| actually stored. | ||
|
|
||
| :param data: The raw bytes to store. | ||
| :param media_type: Optional MIME type to record on the returned ref. | ||
| :param key: Optional explicit key to store under. If not provided, a content-addressed | ||
| key (the hex digest) is used -- so writing identical content twice is a no-op. | ||
| :raises ValueError: If ``key`` is explicitly provided and already exists with content | ||
| that does not match the digest of ``data``. | ||
| :return: An :py:class:`ArtifactRef` describing the stored artifact. | ||
| """ | ||
| digest = hashlib.sha256(data).hexdigest() | ||
| resolved_key = key if key is not None else digest | ||
| if key is not None and self.exists(resolved_key): | ||
| existing_digest = hashlib.sha256(self.get(resolved_key)).hexdigest() | ||
| if existing_digest != digest: | ||
| raise ValueError( | ||
| f"Key '{resolved_key}' already exists with different content (existing " | ||
| f"digest {existing_digest}, new digest {digest}). Use a different explicit " | ||
| f"key, or omit `key` to use content-addressed storage." | ||
| ) | ||
| # Identical content is already stored under this key -- nothing to do. | ||
| else: | ||
| self.put(data, resolved_key) | ||
| return ArtifactRef( | ||
| key=resolved_key, size_bytes=len(data), digest=digest, media_type=media_type | ||
| ) | ||
|
|
||
| def get_artifact(self, ref: ArtifactRef, verify: bool = True) -> bytes: | ||
| """Fetches the bytes described by ``ref``, optionally verifying its digest. | ||
|
|
||
| :param ref: The reference describing what to fetch. | ||
| :param verify: Whether to verify the fetched bytes match ``ref.digest``. Defaults to True. | ||
| :raises ValueError: If ``verify`` is True and the fetched bytes' digest does not match. | ||
| """ | ||
| data = self.get(ref.key) | ||
| if verify: | ||
| actual_digest = hashlib.sha256(data).hexdigest() | ||
| if actual_digest != ref.digest: | ||
| raise ValueError( | ||
| f"Digest mismatch for artifact '{ref.key}': " | ||
| f"expected {ref.digest}, got {actual_digest}. The stored content may have " | ||
| f"been overwritten or corrupted." | ||
| ) | ||
| return data | ||
|
|
||
|
|
||
| class LocalFileSystemArtifactStore(ArtifactStore): | ||
| """An :py:class:`ArtifactStore` backed by a directory on the local filesystem. | ||
| Has no third-party dependencies. For cloud-backed stores (S3, GCS, ...) see | ||
| ``burr.integrations.artifacts``. | ||
| """ | ||
|
|
||
| def __init__(self, root_dir: str): | ||
| """Constructor. | ||
|
|
||
| :param root_dir: Directory to store artifacts under. Created if it does not exist. | ||
| """ | ||
| self.root_dir = root_dir | ||
| os.makedirs(root_dir, exist_ok=True) | ||
|
|
||
| def _path_for_key(self, key: str) -> str: | ||
| # Resolve symlinks (os.path.realpath) rather than just normalizing (os.path.abspath) -- | ||
| # otherwise a symlink inside root_dir pointing outside of it would let a key escape the | ||
| # store undetected. realpath resolves symlinks in existing path components even when the | ||
| # final path segment (e.g. a not-yet-written artifact file) does not exist yet. | ||
| root = os.path.realpath(self.root_dir) | ||
| path = os.path.realpath(os.path.join(root, key)) | ||
| if os.path.commonpath([root, path]) != root: | ||
| raise ValueError( | ||
| f"Invalid artifact key '{key}': resolves outside of the store's root directory." | ||
| ) | ||
| return path | ||
|
|
||
| def put(self, data: bytes, key: str) -> None: | ||
| path = self._path_for_key(key) | ||
| if os.path.exists(path): | ||
| # content-addressed keys make writes idempotent -- skip re-writing existing data. | ||
| return | ||
| directory = os.path.dirname(path) | ||
| os.makedirs(directory, exist_ok=True) | ||
| # Write to a temp file in the same directory and atomically rename it into place, so | ||
| # concurrent readers/crashes never observe a partial/truncated file at `path` -- either | ||
| # `path` doesn't exist yet, or it's fully written. | ||
| fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".tmp-artifact-") | ||
| try: | ||
| with os.fdopen(fd, "wb") as f: | ||
| f.write(data) | ||
| f.flush() | ||
| os.fsync(f.fileno()) | ||
| os.replace(tmp_path, path) | ||
| except BaseException: | ||
| try: | ||
| os.remove(tmp_path) | ||
| except OSError: | ||
| pass | ||
| raise | ||
|
|
||
| def get(self, key: str) -> bytes: | ||
| path = self._path_for_key(key) | ||
| if not os.path.exists(path): | ||
| raise FileNotFoundError(f"Artifact '{key}' not found in store at '{self.root_dir}'.") | ||
| with open(path, "rb") as f: | ||
| return f.read() | ||
|
|
||
| def exists(self, key: str) -> bool: | ||
| return os.path.exists(self._path_for_key(key)) | ||
|
|
||
|
|
||
| _ARTIFACT_REF_SERDE_KEY = "ArtifactRef" | ||
|
|
||
|
|
||
| @serde.serialize.register(ArtifactRef) | ||
| def _serialize_artifact_ref(value: ArtifactRef, **kwargs) -> dict: | ||
| return { | ||
| serde.KEY: _ARTIFACT_REF_SERDE_KEY, | ||
| "key": value.key, | ||
| "size_bytes": value.size_bytes, | ||
| "digest": value.digest, | ||
| "media_type": value.media_type, | ||
| } | ||
|
|
||
|
|
||
| @serde.deserializer.register(_ARTIFACT_REF_SERDE_KEY) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This deserializer is registered only as an import side effect of |
||
| def _deserialize_artifact_ref(value: dict, **kwargs) -> ArtifactRef: | ||
| return ArtifactRef( | ||
| key=value["key"], | ||
| size_bytes=value["size_bytes"], | ||
| digest=value["digest"], | ||
| media_type=value.get("media_type"), | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| # 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Blocking: we need to handle the async Python case. Async Python is a first-class execution model in Burr, but this API is synchronous and the S3 implementation performs blocking network I/O through boto3. Using this store from an async action would therefore block the event loop for the duration of an artifact upload or download. Please add an async artifact-store contract and a genuinely async S3 implementation, and define how that store is configured and exposed to async applications. The alternative would be to restrict artifact storage to synchronous Burr explicitly, which would leave a significant part of Burr unsupported.