# `SerdeL2AdapterWrapper` — Transparent Serde via Adapter Composition
## Scope
Describes how serialization / deserialization is integrated into the
L2 path. The serde package itself
([`docs/design/v1/distributed/serde/README.md`](../serde/README.md))
defines the generic `Serializer` / `Deserializer` / `SerdeProcessor`
interfaces and the fp8 built-in. **This** doc is about the adapter
that stitches serde into the distributed storage pipeline.
## Design Summary
`SerdeL2AdapterWrapper` implements `L2AdapterInterface` by composing
an inner L2 adapter with a `SerdeProcessor` and an `L1Manager`. The
caller sees the wrapper's public API only; the wrapper's internal
thread is the sole consumer of the inner adapter's and serde's event
fds.
Step numbers below show **call ordering**. Solid `─►` arrows are
direct synchronous calls; dotted `╌►` arrows are eventfd wakeups
consumed by the wrapper's internal thread.
### Store path
```
caller
│
│ (1) submit_store_task(keys, objs)
▼
┌─────────────────┐ (2) reserve_write(tmp) ┌──────────────┐
│ wrapper (API) │ ─────────────────────────►│ L1Manager │
│ │ ◄────── tmp_objs ─────────│ │
└────────┬────────┘ └──────────────┘
│ (3) submit_serialize(objs, tmp_objs)
▼
┌─────────────────┐
│ SerdeProcessor │ transforms objs → tmp_objs
└────────┬────────┘
╎ (4) serialize_efd
▼
┌─────────────────┐ (5) inner.submit_store_ ┌──────────────┐
│ wrapper thread │ task(keys, tmp_objs) │ inner L2 │
│ (_loop) │ ─────────────────────────► │ adapter │
└────────┬────────┘ └──────┬───────┘
▲ │
╎ (6) inner.store_efd ◄──────────────────────┘
│
┌────────┴────────┐
│ wrapper thread │ (7) finish_read(tmp_objs) → auto-delete
│ │ signal wrapper.store_efd
└────────┬────────┘
│
│ (8) pop_completed_store_tasks() →
│ {wrapped_id: L2StoreResult(success=True,
│ bytes_transferred=N)}
▼
caller
```
### Load path
```
caller
│
│ (1) submit_load_task(keys, dst_objs)
▼
┌─────────────────┐ (2) reserve_write(tmp) ┌──────────────┐
│ wrapper (API) │ ─────────────────────────►│ L1Manager │
│ │ ◄────── tmp_objs ─────────│ │
└────────┬────────┘ └──────────────┘
│ (3) inner.submit_load_task(keys, tmp_objs)
▼
┌─────────────────┐
│ inner L2 │ loads serialized bytes into tmp_objs
│ adapter │
└────────┬────────┘
╎ (4) inner.load_efd
▼
┌─────────────────┐ (5) submit_deserialize ┌──────────────┐
│ wrapper thread │ (tmp_objs, dst_objs) │ Serde │
│ (_loop) │ ─────────────────────────► │ Processor │
└────────┬────────┘ └──────┬───────┘
▲ │
╎ (6) deserialize_efd ◄──────────────────────┘
│
┌────────┴────────┐
│ wrapper thread │ (7) finish_write + delete(tmp_objs)
│ │ signal wrapper.load_efd
└────────┬────────┘
│
│ (8) query_load_result() → per-key bitmap
▼
caller
```
## Store Path
```mermaid
sequenceDiagram
participant Controller as StoreController
participant W as SerdeL2AdapterWrapper
participant Thread as wrapper internal thread
participant Serde as SerdeProcessor
participant L1 as L1Manager
participant Inner as inner L2 adapter
Controller->>W: submit_store_task(keys, objs)
W->>L1: reserve_write(temp_keys, byte layout)
L1-->>W: temp_objs (write-locked)
W->>Serde: submit_serialize(objs, temp_objs) → serde_id
W-->>Controller: wrapped_id
Note over Serde,Thread: serde thread pool transforms
objs → temp_objs, signals fd
Serde-->>Thread: serialize_efd fires
Thread->>Serde: query_serialize_result(serde_id)
Serde-->>Thread: success
Thread->>L1: finish_write_and_reserve_read(temp_keys)
Thread->>Inner: submit_store_task(keys, temp_objs) → inner_id
Note over Inner: inner stores serialized bytes,
signals its store_efd
Inner-->>Thread: store_efd fires
Thread->>Inner: pop_completed_store_tasks()
Inner-->>Thread: {inner_id: L2StoreResult(ok=True, bytes=N)}
Thread->>L1: finish_read(temp_keys) %% auto-deletes temps
Thread->>W: _finalize_store(wrapped_id, inner_result)
W-->>Controller: store_efd fires
Controller->>W: pop_completed_store_tasks()
W-->>Controller: {wrapped_id: L2StoreResult(ok=True, bytes=N)}
```
## Load Path
```mermaid
sequenceDiagram
participant Controller as PrefetchController
participant W as SerdeL2AdapterWrapper
participant Thread as wrapper internal thread
participant Inner as inner L2 adapter
participant Serde as SerdeProcessor
participant L1 as L1Manager
Controller->>W: submit_load_task(keys, dst_objs)
W->>L1: reserve_write(temp_keys, byte layout)
L1-->>W: temp_objs (write-locked)
W->>Inner: submit_load_task(keys, temp_objs) → inner_id
W-->>Controller: wrapped_id
Note over Inner: inner loads serialized bytes
into temp_objs, signals load_efd
Inner-->>Thread: load_efd fires
Thread->>Inner: query_load_result(inner_id)
Inner-->>Thread: bitmap (per-key success)
alt all-zeros bitmap
Thread->>L1: finish_write + delete temp_keys
Thread->>W: _finalize_load(wrapped_id, bitmap)
else some keys loaded
Thread->>Serde: submit_deserialize(temp_objs, dst_objs) → serde_id
Note over Serde: serde thread transforms
temp_objs → dst_objs
Serde-->>Thread: deserialize_efd fires
Thread->>Serde: query_deserialize_result(serde_id)
alt success
Thread->>L1: finish_write + delete temp_keys
Thread->>W: _finalize_load(wrapped_id, bitmap)
else failure
Thread->>L1: finish_write + delete temp_keys
Thread->>W: _finalize_load(wrapped_id, Bitmap(0, len))
Note right of W: zero bitmap ⇒ controller
treats keys as load failures
end
end
W-->>Controller: load_efd fires
Controller->>W: query_load_result(wrapped_id)
W-->>Controller: final bitmap
```
## Lookup / Unlock / Eviction
These paths don't involve any transform, so the wrapper delegates
directly to the inner adapter — including the lookup event fd
itself, to avoid an unnecessary thread hop per lookup.
| API | Behavior |
|---|---|
| `get_lookup_and_lock_event_fd` | Returns the inner adapter's fd (pass-through) |
| `submit_lookup_and_lock_task` / `query_lookup_and_lock_result` | Delegated directly |
| `submit_unlock` | Delegated directly |
| `delete` / `get_usage` / `supports_global_eviction` | Delegated directly |
| `register_listener` | Registers on the inner adapter (listeners track real storage state) |
The wrapper does **not** maintain its own byte accounting — it
reports whatever the inner adapter reports. This keeps
`L2EvictionController` and per-cache_salt quota logic unchanged:
they see the inner adapter's byte totals through the wrapper's
`get_usage()`.
## Temp Buffer Lifecycle
Temp byte buffers are the only new L1 state the wrapper introduces.
They exist entirely within the wrapper's knowledge — the caller never
sees the temp keys.
**Store path:**
1. `reserve_write(temp_keys, is_temporary=True, layout=ser_layout, mode="new")`
— temps are write-locked and marked temporary so
`finish_read` will auto-delete them later.
2. Serialize runs, filling temps.
3. On success: `finish_write_and_reserve_read(temp_keys)` — temps
become read-locked so `inner.submit_store_task` can safely read
them.
4. Inner store completes → `finish_read(temp_keys)` — since
`is_temporary=True`, finish_read also deletes them.
5. On serialize or inner failure: `finish_write(temp_keys) + delete(temp_keys)`
while temps are still write-locked.
**Load path:**
1. `reserve_write(temp_keys, is_temporary=True, layout=ser_layout, mode="new")`
— same as store, temps write-locked.
2. `inner.submit_load_task(keys, temp_objs)` — inner loads serialized
bytes into temps.
3. Inner completes → `submit_deserialize(temp_objs, dst_objs)` — note
temps stay write-locked (the wrapper owns them; only the wrapper
reads them during deserialize).
4. Deserialize completes → `finish_write(temp_keys) + delete(temp_keys)`
regardless of deserialize success.
## Failure Policy: All-or-Nothing per Submit
If **any** key's temp allocation fails or `submit_serialize` /
`submit_deserialize` / `inner.submit_*` raises, the **whole wrapped
task fails**:
- Store task: `pop_completed_store_tasks()` returns
`{wrapped_id: L2StoreResult(success=False, bytes_transferred=0)}`.
- Load task: `query_load_result(wrapped_id)` returns an all-zeros
`Bitmap(len(keys))`.
This preserves the **coarse-grained success semantic** of
`L2AdapterInterface` — store is task-level (the `L2StoreResult.is_successful()`
flag), load is per-key via bitmap. The alternative ("drop the failed keys,
succeed with the rest") would silently violate the caller's assumption
that every key it passed either succeeded (task-level) or failed
(task-level). Keeping the policy coarse is what lets the controllers
stay untouched.
Partial load failures inside the inner adapter (some keys in the
inner bitmap succeed, some fail) are faithfully preserved: only keys
with `bitmap.test(i) == True` are deserialized, and the wrapper
reports the same bitmap to the controller (zeroed if deserialize
itself fails).
## Homogeneity Invariant
`_alloc_temp_buffers` sizes temps from `objects[0].get_shapes() /
get_dtypes()` under the assumption that **all MemoryObjs in one
submit share a single layout**. The store controller already
shape-groups keys before calling `submit_store_task`; the prefetch
controller uses a single `layout_desc` per request for all write
reservations. The wrapper enforces the invariant with an explicit
`raise ValueError` to catch future regressions.
## Threading Model
One background thread per wrapper instance, started in `__init__`.
- Poll loop over four fds: `inner.store_efd`, `inner.load_efd`,
`serde.serialize_efd`, `serde.deserialize_efd`.
- Single `threading.Lock` protecting: the task-id counter, the four
reverse-lookup dicts, the two completion dicts, and the phase flip
on store tasks.
- `close()` sets a stop flag and joins the thread; the poll timeout
(`500 ms`) bounds shutdown latency.
- `_finalize_store` / `_finalize_load` write 1 to the wrapper's own
eventfd to wake the upstream controller's poll loop.
The wrapper does **not** share any state with the inner adapter or
the `SerdeProcessor` beyond the public interface — both are
independent thread-safe components, and the wrapper's thread
interacts with them only through their documented APIs.
## Known Limitations / Follow-ups
- **Temp buffer allocation per submit.** Every store / load call
issues an `L1Manager.reserve_write` for fresh temp keys. A free-list
keyed on `(shape, dtype)` would skip the allocator on the hot path.
Not done in the initial version — correctness first.
- **Bookkeeping-dict duplication.** Six of the existing L2 adapters
maintain their own `_next_task_id + lock + completion_dict + eventfd`
quartet; `AsyncSerdeProcessor` is a seventh; this wrapper is an
eighth. An `EventfdTaskQueue` base helper could dedupe this across
the tree — a separate cleanup PR.
- **No wrapper-level metrics.** `report_status` delegates to the inner
and adds `{"serde_wrapped": True}`. Adding per-serde-step latency
histograms would need either new metrics from `AsyncSerdeProcessor`
or timing hooks in the wrapper's drains.