Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agents/DEVELOPING.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ Cheat sheet of what's been considered. Issue numbers are the entry points — fe
**Active design themes**
- **Cache thread-safety / concurrency** (umbrella #444). Enabling refactor #569 is **fully landed** (PRs #601, #604, #622): `BaseCache(OperationContext)` and `Cache(PerKeyLockMixin, BaseCache)` — lock mixins now attach at the cache layer too. Storage layer was already thread-safe (per-key locks; #213, #449 closed). Cache-level races whose **fixes landed**: #217 (back-fill via PR #629, issue closed), #452 (transfer contains→save TOCTOU via PR #630, issue closed), #485 (in-flight dedup window via PR #627, issue closed). #451 (redigest atomicity) — PR #631 shipped the cross-key lock but the **issue is still open** pending follow-up review; cite the PR, not "#451 closed". Still open: `gc` #450, `expand` lock scope #453. (Wrapper check→execute→save #448 closed not-planned.) `BackgroundSaveMixin` (#447) is the planned vehicle for moving disk I/O off the hot path while `save()` keeps returning a key synchronously.
- **Performance hot-spots** (#625, #440). One open digest hot-spot: `_digest_bytes` returns hex-encoded bytes that double parent SHA256 input at every nesting level — fix needs a `hash_version` bump + `Cache.redigest` migration. BLAKE3 tree-hashing was the structural alternative; the hash-function recon issue (#614, **closed 2026-06-02**) concluded with **blake2b(digest_size=32)** as the cheaper migration (stdlib, ~35-40% faster on tree workloads, 64-char hex preserved so `DIGEST_LENGTH` / SQL schema / `D()` pass-through all stay put). #615 is the open Option-A switchover; semver-breaking (invalidates persistent keys; users run `redigest()` once on upgrade). `Perf audit:` issues are refreshed periodically — check the latest one (currently #625, refreshed 2026-07-02) for current numbers; previous audits #527 (2026-05-28), and the older ones it supersedes, are closed. The 2026-06-11/18/25 runs all pin PR #622's cache-layer `PerKeyLockMixin` double-lock penalty (×1.54–2.0 on memory-backend miss/save across all data_heavy workloads — hit/contains unchanged); the storage-layer side of the same double-lock motivated the `init=False, hash=False` storage-dict option in #634's review, which the `__init_subclass__` guard in PR #683 sidestepped without addressing the perf side, so the double-lock cost is still open. The 2026-06-18 run's ×13 H5+Sql data_heavy miss spike did **not** recur on 2026-06-25 (7.8 ms vs 8.3 ms) — confirmed I/O jitter, not a code regression — but `BagOfHoldingH5File` still pays a structural ×5 over `PickleFile` on save (full HDF5 file open/close per `put`/`get`); fix candidates are h5py SWMR / pooled file handles in `bagofholding_file.py`. Hot-spot #2 from earlier audits (per-element `sha256()` allocation in recursive iterables/dicts) was retracted on review — the per-element context is load-bearing for the Merkle-tree property and has no cheap fix. SQL evict hot-spot from the 2026-05-21 audit was resolved by PR #535 (bulk DELETE in place of ORM materialisation), and the earlier BagOfHolding double-open by PR #616.
- **Distributed / remote caching** (#552; #551 **landed**). Still open: #552 — a `TieredValues` + `GlobusValues` cold tier for HPC value blobs that `Cache.query()` never touches, only `Cache.load_value()` on a hot miss.
- **Distributed / remote caching** (#552; #551 **landed**). `SshCache` refuses `Path` values outright (`RemotePathUnsupported`, a `SaveError` subclass so a path argument degrades to a locally-computed digest-only reference and a path result becomes `Rejected`) — a pickled `Path` is only its string, so the server was resolving it against its own filesystem and filing records under keys no client recomputes. **#829** tracks making them actually work: run the path→blob reduction client-side (the blobs' `__digest__` already matches the `Path` digest arm) plus one new unmended `load_value` verb. Still open: #552 — a `TieredValues` + `GlobusValues` cold tier for HPC value blobs that `Cache.query()` never touches, only `Cache.load_value()` on a hot miss.
- **Config redesign** (#568). Next-generation TOML/YAML schema with top-level `value`/`call`/`stash`/`metadata` namespaces and cross-file named references. Supersedes the just-landed merged-discovery model (PR #553) once the schema firms up; expect a `hash_version`-style migration story.
- **Metadata extensions** (#567). `Resources` metadata (peak memory, CPU time) is the next built-in to add on top of the `Runtime` / `Environment` / `Git` / `Tags` set. A standalone `Version` metadata (PR #573) was deliberately folded into `Environment`, which now records `fleche_version` + `python_version` — so a separate version namespace is *not* the direction.
- **Path / file handling** (#516, #517, #522, #33). Pushing toward first-class `Path` support where files are transparently stashed and restored. Open questions: digest of empty/missing files (#517), unifying `FilePath`/`DirectoryPath` under `Path` (#33). `isolate=True` removal is in flight via PR #523 (closes #522).
Expand Down
47 changes: 47 additions & 0 deletions docs/dev/path_storage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,53 @@ name is treated as incidental (typically a temp dir). If a root name carries
meaning, name a *child* meaningfully instead, or wrap the tree's identity in your
own value.

Why paths stop at the SSH boundary
----------------------------------

:class:`~fleche.remote.SshCache` is the one cache where "store this path"
cannot mean what it means everywhere else, because the two sides do not share
a filesystem. Values cross the wire by cloudpickle, and a pickled ``Path`` is
only its string — so a path handed to the remote is a *name it resolves
itself*. Three things follow, and each of them is silent:

* ``save_value`` on the remote runs :class:`PathValueMixin` against the
**server's** disk. If a file happens to sit at that name it is stored, under
the digest of *those* bytes; ``digest(path) == values.save(path)`` — the
invariant the seal in :meth:`~fleche.caches.BaseCache.prepare` rests on —
is broken, and the record lands under a key no client ever recomputes.
* if nothing sits there, the server raises ``Indigestible`` from the middle of
an RPC.
* ``load_value`` materializes into a temp directory on the **server** and can
only ship the name back. The :class:`TempPath` guard does not survive the
hop either — ``PurePath.__reduce__`` reconstructs from ``parts`` alone, so
the ``_temp_root`` attribute is dropped and the class-level ``_live_roots``
registry is per-process — so the server frees the tree as soon as its own
reference dies, and the client is left with a dangling name for a filesystem
it cannot see.

So :class:`~fleche.remote.SshCache` refuses instead, via
:func:`~fleche.storage.paths.find_path` (which walks a value exactly the way a
destructuring save does, using
:func:`~fleche.storage.destructuring.child_slots`) and
:class:`~fleche.remote.RemotePathUnsupported`. That exception subclasses
:class:`~fleche.storage.SaveError` so the existing two-phase-save degradations
carry it: a path argument becomes a digest-only reference **whose digest was
computed locally**, which keeps the seal intact and lookups correct, and a path
result becomes :class:`~fleche.caches.Rejected`, so the call runs uncached
rather than wrongly cached. See :ref:`file-remote-caches` for the user-facing
version.

Making paths genuinely work over SSH is a separate feature, tracked in
`issue #829 <https://github.com/pmrv/fleche/issues/829>`_. The shape is
already visible above: :meth:`PathValueMixin.save` reduces a path to ``bytes``
plus a :class:`FileBlob` / :class:`DirectoryBlob`, all of which ship fine, and
those blobs' ``__digest__`` is *defined* to match the ``Path`` arm — so running
the reduction **client-side** keeps the seal intact by construction. The one
new piece is on the load side: an unmended ``load_value`` that hands back the
blob instead of materializing it on the server, so the client materializes into
its own temp directory and owns the :class:`TempPath` lifetime. That is a
change to the RPC surface, not to this module.

See also
--------

Expand Down
18 changes: 18 additions & 0 deletions docs/recipes/files_and_paths.rst
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,21 @@ their names.
directory's own ``.name`` is a hash (its children's names are faithful).
Don't rely on the top-level directory name surviving a cache hit; do rely on
everything inside it.

Sharing files with a remote (SSH) cache
---------------------------------------

Return ``bytes``. A :class:`~pathlib.Path` cannot cross a
:class:`~fleche.remote.SshCache` — only the path string would travel, and the
remote would resolve it against its own filesystem — so fleche refuses it and
runs the call uncached. Content ships and deduplicates normally:

.. code-block:: python

@fleche
def render(text) -> bytes: # not `-> Path`
return _render_to_pdf(text)

If you want path semantics *and* a remote cache, put a local layer in front of
it: saves land in the local layer, so paths never reach the wire. See
:ref:`file-remote-caches` for the full contract.
36 changes: 36 additions & 0 deletions docs/usage/file_semantics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,41 @@ and re-caching adds a tiny name record, never a second copy of the bytes. If
you want *keys* (not just storage) to ignore the name too, return ``bytes``
instead of a ``Path``; that is the content-only escape hatch.

.. _file-remote-caches:

Paths stop at a remote (SSH) cache
----------------------------------

Everything above assumes the cache and your process see the **same
filesystem**. A :class:`~fleche.remote.SshCache` does not: it forwards each
operation to a fleche running on another machine, and values travel by
cloudpickle — which reduces a ``Path`` to its *string*. Only the name would
arrive, and the remote would resolve it against its own filesystem, storing
whatever happens to sit there (or nothing) under a digest that no longer
matches yours.

Rather than store something else under your key, fleche refuses:
:class:`~fleche.remote.RemotePathUnsupported` is raised when a path — bare or
nested in a container — would cross the wire. Because it is a
:class:`~fleche.storage.SaveError`, the usual degradations apply and your code
does not have to catch anything:

* a path **argument** falls back to a digest-only reference. The call is still
keyed correctly (the digest is computed *here*, from your file), so lookups
hit and miss exactly as they should; only the file's bytes are not retrievable
from the remote record.
* a path **result** is rejected: the call runs, returns your file, and is
logged as not cached.
* **loading** a path stored on the remote raises too. The remote materializes
into a temp directory on *its* disk and can only send back the name, which
means nothing here. This is lazy — a record whose result is a path can still
be loaded and queried; only touching the path value raises.

To share file content across machines, return the file's ``bytes``: they
travel and deduplicate normally. To keep full path semantics, put a local
cache layer in front of the remote one (see :doc:`/notebooks/CacheStack`) — the
local layer is where saves land, so paths never reach the wire.

Quick reference
---------------

Expand All @@ -232,6 +267,7 @@ Return same path twice Two independent copies
Return path inside namedtuple/set/custom class The *original* location (may be stale)
Pass a nonexistent ``Path`` argument No caching: warns, always executes
Write into a directory you received Hit (keyed as passed); write not replayed
Return a ``Path`` through an ``SshCache`` Refused: runs, warns, not cached
=============================================== ======================================

See also
Expand Down
81 changes: 77 additions & 4 deletions src/fleche/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@
from .caches import BaseCache, Rejected
from .call import DigestedCall, LazyCall, QueryCall
from .digest import Digest
from .storage.base import SaveError
from .storage.paths import find_path

logger = logging.getLogger("fleche.remote")

Expand Down Expand Up @@ -346,6 +348,35 @@ class RemoteConnectionError(RuntimeError):
"""Raised when the SSH subprocess cannot be reached or has died."""


class RemotePathUnsupported(SaveError):
"""Raised when a :class:`~pathlib.Path` would cross the SSH boundary.

Path values are stored **by content** (see
:class:`~fleche.storage.paths.PathValueMixin`), but the RPC ships values
by cloudpickle, and a pickled ``Path`` is just its *string*. Letting one
through would hand the server a name to resolve against its **own**
filesystem: it stores whatever happens to sit at that location — a
different file, or nothing — under a digest that no longer matches the
client's ``digest(path)``. The mismatch silently breaks the
``digest(x) == save_value(x)`` seal every lookup depends on, so the
record is filed under a key no client can ever reproduce.

Subclasses :class:`~fleche.storage.base.SaveError` so the standard
save-side degradations apply and nothing has to special-case it: a path
*argument* falls back to a digest-only reference (correct local digest,
lookups still hit, only the bytes are unavailable remotely), and a path
*result* turns into :class:`~fleche.caches.Rejected` — the call runs and
returns normally, it just is not cached. On the **load** side it
propagates to the caller instead: a path the server materialized into
its own temp directory is meaningless here, and the guard's whole point
is to say so rather than hand back a dangling name.

This is a guard, not a verdict: doing the path-to-blob reduction on the
*client* would make paths work over SSH with the seal intact. Tracked in
issue #829.
"""


def _warn_on_version_skew(info: dict[str, Any]) -> None:
"""Log a warning when the remote's fleche or cloudpickle version differs.

Expand Down Expand Up @@ -707,6 +738,13 @@ class SshCache(BaseCache):
ControlPersist in ``~/.ssh/config`` or via *ssh_options* to share the
underlying connection across multiple fleche runs.

**Filesystem paths do not cross this cache.** Values travel by
cloudpickle, so a :class:`~pathlib.Path` would arrive as a bare string for
the remote to resolve against its own disk; both directions raise
:class:`RemotePathUnsupported` instead. Ship the file's ``bytes``, or put
a local layer in front of the remote one — see that exception and
:ref:`file-remote-caches`.

Args:
host: SSH target, e.g. ``"user@host"`` or any alias from
``~/.ssh/config``.
Expand Down Expand Up @@ -785,20 +823,55 @@ def _rpc(self, name: str, *args: Any) -> Any:
raise Rejected(self, *args)
return spec.unwrap(self, self._conn.call(name, *args))

@staticmethod
def _reject_path(value: Any, what: str) -> None:
"""Raise :class:`RemotePathUnsupported` if *value* carries a ``Path``.

Scans the same object graph a destructuring save would walk, so a
path nested in a list, dict, dataclass, or ``attrs`` instance is
caught too — those reach the server's value storage exactly like a
bare one and fail the same way.
"""
path = find_path(value)
if path is not None:
raise RemotePathUnsupported(
f"{what} carries the filesystem path {str(path)!r}, which cannot "
"cross an SshCache: values travel by cloudpickle, so only the "
"path string would arrive and the remote would resolve it "
"against its own filesystem. Return the file's `bytes` instead "
"to store its content, or keep path-valued calls in a local "
"cache layer."
)

def save(self, call: DigestedCall | _call.Call) -> str:
# A DigestedCall carries only digests; the degenerate live-`Call`
# form still carries values, and the server would stash them itself.
if isinstance(call, _call.Call):
try:
self._reject_path(dict(call.arguments), "the call's arguments")
self._reject_path(call.result, "the call's result")
except RemotePathUnsupported as e:
raise Rejected(e) from None
return self._rpc("save", call)

def load(self, key: str) -> LazyCall:
return self._rpc("load", key)

def load_value(self, key: str) -> Any:
return self._rpc("load_value", key)
# The server materializes a stored path into a temp directory on
# *its* filesystem and can only ship us the name — which points
# nowhere here, and which the server unlinks as soon as its own
# reference dies. Refuse it rather than return a dangling path.
value = self._rpc("load_value", key)
self._reject_path(value, f"the value at {key}")
return value

def save_value(self, value: Any) -> Digest:
# One round trip per value (no batching yet). Values travel by
# cloudpickle, so a Path value ships its path *string*, not its
# content — the same limitation the previous ship-the-whole-Call
# protocol had; paths over SSH remain unsupported.
# cloudpickle, so a Path would ship its path *string*, not its
# content — see `RemotePathUnsupported` for why that is refused
# rather than silently stored against the server's filesystem.
self._reject_path(value, "the value")
return self._rpc("save_value", value)

def evict(self, key: str | Digest) -> None:
Expand Down
6 changes: 4 additions & 2 deletions src/fleche/storage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
CallStorage,
CallMixin,
)
from .destructuring import DestructuringMixin, register_destructurer
from .paths import PathValueMixin, TempPath, FileBlob, DirectoryBlob
from .destructuring import DestructuringMixin, child_slots, register_destructurer
from .paths import PathValueMixin, TempPath, FileBlob, DirectoryBlob, find_path
from .memory import ValueMemory, CallMemory
from .void import ValueVoid, CallVoid
from .file import FileStorage
Expand All @@ -38,11 +38,13 @@
"CallStorage",
"CallMixin",
"DestructuringMixin",
"child_slots",
"register_destructurer",
"PathValueMixin",
"TempPath",
"FileBlob",
"DirectoryBlob",
"find_path",
"ValueMemory",
"CallMemory",
"ValueVoid",
Expand Down
30 changes: 30 additions & 0 deletions src/fleche/storage/destructuring.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,36 @@ def _field_items(value: Any) -> list[tuple[str, Any]]:
]


def child_slots(value: Any) -> list[tuple[Any, Any]] | None:
"""Enumerate the ``(label, child)`` pairs a save would recurse into.

The read-only half of :meth:`DestructuringMixin._intern_rec`'s dispatch:
same leaf rules (numbers / strings / bytes / digests / namedtuples are
opaque), same ``_DESTRUCTURERS`` lookup, but it only *reports* the
children instead of interning them. ``None`` means "opaque leaf" — the
value has no children a destructuring save would look inside.

Labels follow the underlying :meth:`Digested._slots`: field names for
dataclass / ``attrs`` instances, ``None`` for the positional slots of
lists, tuples, and mappings.

A destructurer registered through :func:`register_destructurer` is only
visible here if it is a :class:`Digested` classmethod (i.e. exposes
``_slots``); anything else is reported as an opaque leaf, since there is
no way to enumerate its children without also storing them.
"""
if isinstance(value, (digest.Digest, Number, str, bytes)):
return None
if isinstance(value, tuple) and DestructuringMixin._is_trojan_tuple(value):
return None
for pred, sunder_fn in _DESTRUCTURERS:
if pred(value):
owner = getattr(sunder_fn, "__self__", None)
slots = getattr(owner, "_slots", None)
return None if slots is None else slots(value)
return None


def register_destructurer(pred: Callable[[Any], bool], fn: Callable) -> None:
"""Register a custom container destructurer.

Expand Down
Loading
Loading