Skip to content
Open
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 @@ -95,7 +95,7 @@ just want to *use* the library (decorator, config, backends), see
**Storage layout (`storage/`):**
- `base.py` — `OperationContext(ABC)` (the new root after PR #604) exposes the `_operation_context(key, *, intent=Intent.WRITE)` context-manager hook — chain via `super()._operation_context(key, intent=intent)`; the `intent: Intent` kw-only param (PR #601) is a `StrEnum` with `WRITE` (the default — takes the exclusive lock) and `READ` (a **no-op** today: the lock mixins short-circuit it and acquire nothing; reserved for a future reader-writer/shared lock, so it must never guard a read-modify-write). `CacheStack._operation_context` enters its non-`stack[0]` members with `READ`, so a transfer into a stack only really locks `stack[0]` where the write lands. `KeyManagement(OperationContext)` adds the abstract `list`/`_evict`/`_contains` plus concrete `evict`/`contains`/`expand`/`shrink`/`_normalize_key` (`expand` raises `KeyError` for prefixes shorter than 4 chars). `StorageBackend(KeyManagement)` adds `put`/`get`. Domain ABCs `ValueStorage`, `CallStorage` (the latter also exposes `transform(func=None)` — re-saves every entry through *func*; entries whose `to_lookup_key()` changes are saved under the new key and the old one is evicted, unchanged ones are re-saved idempotently. `func=None` applies the identity, which is the form to use after a hash-function change. The analogous `Cache.redigest` does its own loop because it must update `values` too). **Bridge mixins** `ValueMixin`/`CallMixin` implement `save`/`load`[/`query`] on top of `put`/`get`. Also: `SaveError`, `AmbiguousDigestError`, `_resolve_prefix` (used by both base and `Sql`). `OperationContext`, `KeyManagement`, `Intent`, `StorageBackend`, `ValueStorage`/`ValueMixin`, `CallStorage`/`CallMixin` are all re-exported from `fleche.storage`.
- `file.py` — `FileStorage` base for disk-backed backends (also re-exported from `fleche.storage` for subclassing). Locking is via the `filelock` package: writes use `filelock.FileLock(lock_path, timeout=lock_timeout)` directly; reads go through `_file_read_lock_with_fallback`, which on lock timeout logs a warning and proceeds **without** the lock (a missing/torn file then surfaces as `KeyError`). `root` is resolved (`expanduser`+`absolute`+`resolve`) in `__post_init__`. Subclasses implement `_to_file`/`_from_file`; compression and signing live in `pickle_file.py`, not here.
- `memory.py`, `void.py`, `pickle_file.py` (+`PickleFileBackend.with_pickle`/`with_cloudpickle`/`with_dill`; `compress_all`/`decompress_all` migration helpers; gzip auto-detected by `\x1f\x8b` magic on read), `bagofholding_file.py` (+`rebag(version_validator="none")` re-saves every bag through a chosen validator, useful when older bags would fail strict version checks; `_from_file` opens the bag with `H5Bag(path, _skip_load=True).load(...)` so the file is opened exactly once per read instead of twice — PR #616; optional `prefix_length: int | None = 2` on `BagOfHoldingH5FileBackend` multiplexes keys sharing the first `prefix_length` chars into one `root/{prefix}.h5` file as sibling groups named by the full key, via bagofholding 0.1.12's `file.h5/group` path addressing — single-level, fixed-length split only; the default `2` multi-bags into up to 256 files, `prefix_length=0` restores the one-file-per-key layout, and `prefix_length=None` infers the length from the files already in `root` (empty root → the default `2`) — PR #746 closes #556. The PR #748 add-ons: `refix(n)` copies every entry into a new `prefix_length` layout and returns the re-fixed storage, leaving the receiver invariant (target must be an explicit int, `0` = per-key; fails fast on unreadable entries; resumable — entries already in the target layout are skipped), `__post_init__` raises `ValueError` when the configured `prefix_length` doesn't match the layout of files already in `root` (skippable via the init-only `check_consistency=False`, which requires an explicit `prefix_length` and makes the instance blind to all other layouts' files), and the `consolidate(root, n)` classmethod repairs a mixed-layout root by refixing every other prefix length it finds to `n`), `sql.py` — concrete backends (each exposes `Value*` and/or `Call*` classes; `sql.py` only has `Sql` for calls). `MemoryBackend.put`/`get` deep-copy values, so mutating a stored object after retrieval does not affect later reads. `Sql.query` always pushes name/module/version/code_digest/result and argument filters down to SQL (arguments are compared as `digest(value)` strings via `JOIN`s on the `arguments` table). Metadata filters are pushed down only when *every* filter value is a simple scalar (`str`/`bool`/`int`/`float`) via JSON-extract `as_*` casts; any `None` or complex (e.g. list) value disables metadata pushdown only — name/argument filters still apply at SQL level — and the post-load `meta_matches` check runs on every yielded result regardless. `Sql` does not inherit from `CallMixin` — it implements `CallStorage.save` directly, folding the existing-row check into a private `_persist_call` helper that runs in one transaction (no separate `contains`+`evict` round-trip), with `PerKeyLockMixin` keeping concurrent saves serialised (see `regression/test_sql_concurrent_save.py`). The companion private helper `_fetch_call` materialises a `DigestedCall` from a row plus its argument/metadata child rows; `Sql` does not expose any `put`/`get` methods (it isn't a `StorageBackend` subclass). `_coerce_sqlite_url` accepts a bare path (treated as sqlite, parent dir auto-created), a `sqlite:` URL, or any other SQLAlchemy URL (e.g. `postgresql://`, `mysql+pymysql://`) verbatim. `:memory:` is special-cased. SQLite-only PRAGMAs are enabled via a `connect` event listener (gated on `dialect.name == "sqlite"`): `foreign_keys=ON` always, plus `journal_mode=WAL` for file-backed DBs (skipped for `:memory:`). The schema is three tables: `calls`, `arguments` (one row per arg, `UNIQUE(call_key, name)`, ordered by `position`), `metadata` (one JSON blob per metadata namespace, `UNIQUE(call_key, name)`). `arguments.call_key`/`metadata.call_key` are FKs with `ondelete="CASCADE"`, so `_evict` is a single `session.execute(delete(CallModel).where(...))` (SQLAlchemy DSL, no raw SQL or ORM materialisation) and lets the DB clean up child rows (SQLite needs the `foreign_keys=ON` PRAGMA the dialect listener installs; Postgres/MySQL enforce FK cascades natively). MySQL/MariaDB needs explicit `VARCHAR(255)` for indexable name columns; other dialects get unbounded `TEXT` via `String().with_variant(...)`. `Sql` also accepts `check_same_thread=False` only for sqlite drivers (skipped for Postgres/MySQL where the flag would raise at connect).
- `memory.py`, `void.py`, `pickle_file.py` (+`PickleFileBackend.with_pickle`/`with_cloudpickle`/`with_dill`; `compress_all`/`decompress_all` migration helpers; gzip auto-detected by `\x1f\x8b` magic on read), `bagofholding_file.py` (+`rebag(version_validator="none")` re-saves every bag through a chosen validator, useful when older bags would fail strict version checks; `_from_file` opens the bag with `H5Bag(path, _skip_load=True).load(...)` so the file is opened exactly once per read instead of twice — PR #616; optional `prefix_length: int | None = 2` on `BagOfHoldingH5FileBackend` multiplexes keys sharing the first `prefix_length` chars into one `root/{prefix}.h5` file as sibling groups named by the full key, via bagofholding 0.1.12's `file.h5/group` path addressing — single-level, fixed-length split only; the default `2` multi-bags into up to 256 files, `prefix_length=0` restores the one-file-per-key layout, and `prefix_length=None` infers the length from the files already in `root` (empty root → the default `2`) — PR #746 closes #556. The PR #748 add-ons: `refix(n)` copies every entry into a new `prefix_length` layout and returns the re-fixed storage, leaving the receiver invariant (target must be an explicit int, `0` = per-key; fails fast on unreadable entries; resumable — entries already in the target layout are skipped), `__post_init__` raises `ValueError` when the configured `prefix_length` doesn't match the layout of files already in `root` (skippable via the init-only `check_consistency=False`, which requires an explicit `prefix_length` and makes the instance blind to all other layouts' files), and the `consolidate(root, n)` classmethod repairs a mixed-layout root by refixing every other prefix length it finds to `n`. The #625 multi-bag perf fix adds a **process-wide read-handle cache** (`_BagHandleCache` / module-level `_bag_handles`): `contains`/`list` reuse open read-only `h5py.File` handles instead of paying a full HDF5 open per call, indexed in a `WeakValueDictionary` by absolute path with a strong-ref MRU of `_MAX_OPEN_BAGS` keeping them alive between operations, validated against an `(inode, mtime_ns, size)` stat signature on every acquisition. Handles are opened with `locking=False` so they don't block other processes' writes; the flip side is that HDF5 rejects *any* same-process open whose mode or locking flags differ from a live handle's, so every path that lets H5Bag open the file itself (`_to_file`, `_from_file`, `rebag`) or writes via `h5py.File(..., "a")` (`_evict`) must run inside `_bag_writer(key)`, which closes the cached handle and holds the per-bag in-process lock across the operation; readers hold the same lock for the duration of their handle use (`_bag_reader`). The cache is keyed by path process-wide — not per-instance — precisely because a handle cached by one storage instance must be closable by any other instance addressing the same file. `evict` still pays the write open/close (structural: a durable per-key delete in a shared HDF5 file)), `sql.py` — concrete backends (each exposes `Value*` and/or `Call*` classes; `sql.py` only has `Sql` for calls). `MemoryBackend.put`/`get` deep-copy values, so mutating a stored object after retrieval does not affect later reads. `Sql.query` always pushes name/module/version/code_digest/result and argument filters down to SQL (arguments are compared as `digest(value)` strings via `JOIN`s on the `arguments` table). Metadata filters are pushed down only when *every* filter value is a simple scalar (`str`/`bool`/`int`/`float`) via JSON-extract `as_*` casts; any `None` or complex (e.g. list) value disables metadata pushdown only — name/argument filters still apply at SQL level — and the post-load `meta_matches` check runs on every yielded result regardless. `Sql` does not inherit from `CallMixin` — it implements `CallStorage.save` directly, folding the existing-row check into a private `_persist_call` helper that runs in one transaction (no separate `contains`+`evict` round-trip), with `PerKeyLockMixin` keeping concurrent saves serialised (see `regression/test_sql_concurrent_save.py`). The companion private helper `_fetch_call` materialises a `DigestedCall` from a row plus its argument/metadata child rows; `Sql` does not expose any `put`/`get` methods (it isn't a `StorageBackend` subclass). `_coerce_sqlite_url` accepts a bare path (treated as sqlite, parent dir auto-created), a `sqlite:` URL, or any other SQLAlchemy URL (e.g. `postgresql://`, `mysql+pymysql://`) verbatim. `:memory:` is special-cased. SQLite-only PRAGMAs are enabled via a `connect` event listener (gated on `dialect.name == "sqlite"`): `foreign_keys=ON` always, plus `journal_mode=WAL` for file-backed DBs (skipped for `:memory:`). The schema is three tables: `calls`, `arguments` (one row per arg, `UNIQUE(call_key, name)`, ordered by `position`), `metadata` (one JSON blob per metadata namespace, `UNIQUE(call_key, name)`). `arguments.call_key`/`metadata.call_key` are FKs with `ondelete="CASCADE"`, so `_evict` is a single `session.execute(delete(CallModel).where(...))` (SQLAlchemy DSL, no raw SQL or ORM materialisation) and lets the DB clean up child rows (SQLite needs the `foreign_keys=ON` PRAGMA the dialect listener installs; Postgres/MySQL enforce FK cascades natively). MySQL/MariaDB needs explicit `VARCHAR(255)` for indexable name columns; other dialects get unbounded `TEXT` via `String().with_variant(...)`. `Sql` also accepts `check_same_thread=False` only for sqlite drivers (skipped for Postgres/MySQL where the flag would raise at connect).
- `destructuring.py` — `DestructuringMixin` for recursive value splitting + `Digested` ABC with markers `DigestedIterable` (lists/tuples), `DigestedDict`, and `DigestedFields` (shared base reconstructing instances via `object.__new__` + `__setattr__`, bypassing `__init__`/`__post_init__`, so `init=False`/`InitVar`/frozen/slots fields round-trip) with two concrete subclasses `DigestedDataclass` (stdlib dataclasses) and `DigestedAttrs` (`attrs`-decorated classes). All preserve digest equivalence via `__digest__`. `DestructuringMixin` is a `ValueStorage` subclass — operates at the `save`/`load` layer, not `put`/`get`; compose **above** `ValueMixin` in the MRO. `remaining_depth` (default `1`) controls how deep structures are split across keys. `child_digests(key)→set[Digest]` returns the immediate digest references of a stored entry (raw, pre-`mend`); `count_reuses()` tallies how often each key is referenced as a sub-component (useful for GC-style audits). The `HasChildDigests` `runtime_checkable` Protocol declares the `child_digests` shape so `Cache.gc()` can opt-in to transitive walks via plain `isinstance` — any future value storage that exposes `child_digests` satisfies it without explicit registration. NamedTuples are deliberately **not** destructured (`_is_trojan_tuple` guard). `register_destructurer(pred, fn)` (re-exported from `fleche.storage`) appends a new entry to the module-level `_DESTRUCTURERS` list — first match wins, so registering a handler for a brand-new container type is safe; ordering matters if you want to override list/dict/dataclass/attrs.
- `thread_safe.py` — `SerializingMixin(OperationContext)` (single `_PicklableRLock`), `PerKeyLockMixin(OperationContext)` (striped locks via a module-level `_per_instance_locks: WeakKeyDictionary`; per-instance `WeakValueDictionary[key, RLock]` — so the storage instance must be **hashable**, which all the frozen-dataclass concrete classes are). Both mixins override `_operation_context(key, *, intent=Intent.WRITE)`; chain via `super()._operation_context(key, intent=intent)`. Both short-circuit `intent=Intent.READ` to a plain `yield` (no lock acquired) — the no-op shared-lock placeholder. Inheriting from `OperationContext` directly (rather than `KeyManagement`) is what lets them attach at *either* the storage layer or the cache layer — `BaseCache(OperationContext)` since PR #622 closed #569. `_PicklableLock`/`_PicklableRLock` survive pickle round-trip with state not preserved — in-process only, NOT inter-process synchronisation.

Expand Down
Loading
Loading