Status: v0.3 — Phase 1 and Phase 2 implemented; Phase 3 native connector in progress (see main and IMPLEMENTATION_PLAN.md)
Audience: Engineers implementing and reviewing an Aerospike storage backend for LMCache.
Scope: A multi-phase plan delivering an Aerospike-backed remote KV-cache tier for LMCache, anchored to LMCache's RemoteConnector plugin contract and a CE-only, adaptive-sharded Aerospike data model tuned for ~4 MiB chunks.
v0.3 reconciliation (verified against upstream LMCache
dev, LMCache native RESP, and the official Aerospike clients). This revision corrects the design against the actual contracts before implementation. The companion build guide isIMPLEMENTATION_PLAN.md. Key changes:
- Server-side limit discovery runs at connector construction, not
post_init()— upstreamRemoteBackendnever callspost_init()on a remote connector (Section 4.3.6, Section 4.4.0).- Batch API corrected to
batch_write(BatchRecords([...]))andbatch_read(keys, []); the removedexists_many/get_many/select_manyhelpers are not used (Section 4.4.4, Section 4.4.7).- Metadata is one serialized
RemoteMetadatablob (mdbin), gated onsave_chunk_meta, mirroringFSConnector— replacing the rigidshape0..shape3/dtype/fmtbins; reads allocate accordingly (Section 4.3.1, Section 4.4.3).- The connector is serde-agnostic;
naive/cachegen/kiviserde and MLA/layerwise key rewriting happen inRemoteBackendabove it (Section 4.4).- Aerospike client pinned to
>=14,<19becausemeta={"ttl": N}is deprecated from19.1.0; per-record cap is server-governed (7.1+max-record-sizedefault 1 MiB), and the ops sweet spot is restated as 1-10 KiB (Section 2.2, Section 4.1).- 4 MiB
target_segment_bytesretained and now cited to the LMCache paper (arXiv:2510.09665); the Aerospike ops sweet spot and the LMCache byte-throughput sweet spot are explicitly distinguished (Section 4.3.4).- Phase 3 follows Redis' native mechanics, not its schema by default: C++ workers, GIL-free pybind submissions, eventfd completions, and direct buffer copies are adopted immediately, while the Phase 1/2 meta+segment schema remains the first native layout. A raw Redis-like schema is reserved for a benchmark-proven follow-up, either as a separate native mode or as a coordinated migration of Phase 1 and Phase 2.
- Purpose, scope, and non-goals
- Background
- Approaches considered
- Phase 1 - Remote Storage Plugin (implementation-ready)
- Phase 2 - StoragePluginInterface and L2 adapter (architectural)
- Phase 3 - Native C++ connector (implementation-ready direction)
- Open questions
- References
Deliver an Aerospike-backed remote KV-cache tier for LMCache so that vLLM, SGLang, and other LMCache-integrated inference engines can reuse attention KV-cache chunks across worker restarts, across workers on the same node, and across nodes in a cluster, with predictable millisecond-class retrieval latency and TB-scale capacity at lower DRAM cost than an all-DRAM Redis tier.
- A Python package,
lmcache-aerospike, that registers as an LMCache remote storage plugin (remote_storage_plugins: ["aerospike"]) via theConnectorAdapter+RemoteConnectorextension surfaces documented in LMCache remote storage plugins. - An adaptive sharding data model that stores LMCache
MemoryObjpayloads as one or many Aerospike records under deterministic keys, optimized for the ~4 MiB chunk band but correct for arbitrary chunk sizes. - Batch-aware methods (
batched_get,batched_put,batched_contains,batched_async_contains,batched_get_non_blocking) so LMCache prefix-prefetch and write paths can drive concurrent segment I/O. - Operational guidance specific to Aerospike Community Edition (CE) single-cluster deployments, including TTL/NSUP requirements and capacity planning.
- A roadmap that extends the Phase 1 Python connector into Phase 2 (deeper LMCache plugin surfaces) and Phase 3 (native C++ connector) as scale demands.
The following are explicitly not part of Phase 1. Reviewers should not expect them, and the code must not depend on them:
- No RDMA / NIXL transport. LMCache transport mode (prefill/decode handoff) is owned by Mooncake and NIXL. Aerospike participates only in the durable/shared storage tier.
- No GPU-memory extension. This connector never touches GPU HBM directly; it serves the CPU-side remote tier.
- No vector search and no Aerospike Vector Search (AVS). Stored payloads are binary KV tensors, not embeddings.
- No Aerospike Graph (AGS). LMCache does not need graph traversal for its core data path.
- No Aerospike Enterprise Edition (EE) features. Durable deletes, Strong Consistency (SC) namespaces, on-disk compression, role-based access control, TLS-required cluster topology, and XDR cross-datacenter replication are all unavailable in Phase 1. Where EE features would be the natural fit (durable delete, XDR for cross-region cache sharing), the doc calls out the gap and defers to Phase 2+.
- No LMCache controller integration. Cache-location routing and worker/chunk metadata in LMCache's optional controller are handled by LMCache; Phase 1 does not contribute metadata to the controller.
- No participation in LMCache's
StoragePluginInterfaceorL2AdapterInterface. Those are Phase 2. - No native C++ connector. That is Phase 3.
The doc uses these short labels throughout:
| Label | Surface | Status |
|---|---|---|
phase 1: remote connector |
ConnectorAdapter + RemoteConnector (Python) |
Implemented |
phase 2: storage plugin / L2 adapter |
StoragePluginInterface, L2AdapterInterface (Python plugin) |
Implemented |
phase 3: native C++ connector |
ConnectorBase-style C++/pybind11 against libaerospike via LMCache native_plugin |
Implementation in progress |
Phase 1 is "done" when all of the following are true:
pip install lmcache-aerospikesucceeds in a fresh Python 3.10-3.13 environment alongside the LMCache version pinned inpyproject.toml.- An LMCache config with
remote_storage_plugins: ["aerospike"]andextra_configpointing at a running Aerospike CE node round-tripsput->getfor 256 B, 64 KiB, 1 MiB, 4 MiB, 16 MiB, and 64 MiB synthetic chunks. batched_containsandbatched_async_containsreturn the correct consecutive-prefix-length count, matching the upstream Redis connector's behavior bit-for-bit (see[redis_connector.py](https://github.com/LMCache/LMCache/blob/dev/lmcache/v1/storage_backend/connector/redis_connector.py)).- A vLLM + LMCache smoke test shows cache hits across a worker restart when this plugin is the remote tier.
- A bench harness reports p50/p95/p99
getlatency and bytes/s throughput for hit and miss workloads at the 4 MiB segment band.
LMCache is an open-source LLM inference acceleration layer that sits below frameworks like vLLM and SGLang and caches key/value attention tensors for reusable token chunks so repeated long contexts do not have to be prefilled again. It maintains a multi-tier KV hierarchy (GPU HBM, CPU DRAM, local disk/NVMe, remote storage) and exposes pluggable backends for the remote tier. The primary key type is [CacheEngineKey](https://github.com/LMCache/LMCache/blob/dev/lmcache/utils.py), serialized as:
{model_name}@{world_size}@{worker_id}@{chunk_hash_hex}@{dtype}[@tag%value...]
A subclass LayerCacheEngineKey adds an @{layer_id} segment after dtype for layerwise mode. The default chunk size is 256 tokens; the byte size of a chunk depends on model size, tensor parallelism, dtype, layer count, and whether layerwise or MLA modes are enabled, and can range from hundreds of KiB to many MiB. LMCache asynchronously writes chunks from CPU to the remote tier and prefetches consecutive chunk prefixes back into CPU/GPU on cache hit. Operating mode is "storage" (the durable/shared tier we target) versus "transport" (real-time prefill/decode handoff, owned by Mooncake/NIXL and out of scope here).
Aerospike is a distributed, KV-first database. Records live in a namespace.set and are addressed by a user-supplied primary key. Each record has zero-or-more typed bins (columns). The primary index holds roughly 64 bytes per record per replica in RAM and stores a pointer to the record on disk (or in DRAM, depending on storage engine). Operationally relevant constraints for this design:
- Per-record size cap is server-governed, not a fixed 8 MiB. Aerospike's streaming-write-block ceiling is 8 MiB, but on Aerospike 7.1+ the effective per-record cap is governed by
max-record-size, which defaults to 1 MiB and is configurable up to 8 MiB; on Aerospike <=7.0 the implicit cap iswrite-block-size(a power of 2 from 128 KiB to 8 MiB). This connector therefore never hardcodes a cap — it discovers it at startup (see Section 4.3.6) and clamps segment sizes to it. Aerospike's record-size sweet spot for ops-throughput-bound workloads is roughly 1-10 KiB ([model-record-size-hardware-efficiency.md](https://github.com/aerospike/agent-skills/blob/main/skills/aerospike-development/references/model-record-size-hardware-efficiency.md)); larger records still work but stress device bandwidth, replication, and defrag. LMCache is byte-throughput-bound, not ops-bound, so this connector deliberately uses MB-scale segments (see Section 4.3.4). The two "sweet spots" answer different questions and do not conflict. - TTL requires NSUP. A write that carries a positive integer TTL is rejected with
AEROSPIKE_ERR_FAIL_FORBIDDEN(error code 22, "Operation not allowed at this time") if the namespace hasnsup-period 0(NSUP disabled). Special TTL values:0= use the namespace/setdefault-ttl;-1= never expire;-2= don't change void-time on update. See[single-ttl-nsup-default-ttl.md](https://github.com/aerospike/agent-skills/blob/main/skills/aerospike-development/references/single-ttl-nsup-default-ttl.md). - One client per process. The Aerospike client maintains pools and cluster tend state; per-request client creation causes port exhaustion and latency spikes. See
[client-singleton.md](https://github.com/aerospike/agent-skills/blob/main/skills/aerospike-development/references/client-singleton.md). - Batch APIs. The version-stable batch surface is
batch_read(keys, bins)(passbins=[]for metadata-only existence checks) andbatch_write(BatchRecords([...]))built fromaerospike_helpers.batch.records(Write/Read/Remove). The legacyexists_many/get_many/select_manyhelpers were removed from the official Python client and are deliberately not used. Per-key result codes must be inspected because overall success does not imply every sub-operation succeeded. See[batch-parallel-key-operations.md](https://github.com/aerospike/agent-skills/blob/main/skills/aerospike-development/references/batch-parallel-key-operations.md). - No server-side joins. Denormalize and embed; design schema around the primary-key access path. See
[model-access-paths-denormalization.md](https://github.com/aerospike/agent-skills/blob/main/skills/aerospike-development/references/model-access-paths-denormalization.md). - CE limitations. Community Edition does not support durable deletes, Strong Consistency mode, on-disk compression, or XDR; the design must be correct without them.
This is the road map for the rest of the doc:
| LMCache extension surface | Aerospike implementation strategy | Phase |
|---|---|---|
ConnectorAdapter + RemoteConnector (Python, single-process worker) |
aerospike Python client wrapped behind loop.run_in_executor; adaptive sharded data model |
1 |
StoragePluginInterface (Python, full backend, non-multiprocess) |
Same data model; takes ownership of LocalCPUBackend interactions for richer admission control |
2 |
L2AdapterInterface (Python plugin and native_plugin, multiprocess) |
Python L2 wraps Phase 1/2; native_plugin exposes a C++ adapter with eventfd completions |
2 / 3 |
| Native C++ connector (highest throughput, RESP-style mechanics) | pybind11 binding over libaerospike, LMCache native connector protocol, Phase 1/2 schema first |
3 |
Before committing to Phase 1's choice, every plausible integration surface was evaluated. This section is the explicit "list of potential ways" requested in the brief.
- Contract surface. Two abstract classes (
[__init__.py](https://github.com/LMCache/LMCache/blob/dev/lmcache/v1/storage_backend/connector/__init__.py),[base_connector.py](https://github.com/LMCache/LMCache/blob/dev/lmcache/v1/storage_backend/connector/base_connector.py)). RequiredRemoteConnectormethods:exists,exists_sync,get,put,list,close. Optional support methods:batched_get,batched_put,batched_contains,batched_async_contains,batched_get_non_blocking,remove_sync,ping. The adapter declares a URL scheme (aerospike://) and creates the connector from aConnectorContext(URL, loop,LocalCPUBackend, config, metadata, plugin instance name). - Performance ceiling. Limited by the Python Aerospike client's synchronous C extension and the cost of marshalling through
loop.run_in_executor. Adequate for the 100s-10Ks ops/sec band with MB-class payloads, which matches the byte-throughput-dominated profile of LMCache workloads. - Complexity. Lowest of the four surfaces. Fits inside a small Python package with no compiled code.
- Operational surface. Plug-in via
remote_storage_plugins: ["aerospike"]andmodule_path/class_nameinextra_config. No core LMCache changes required. - Wins when. First-customer integrations, single-process LMCache workers, deployments where ops simplicity and time-to-evaluate matter more than peak throughput.
- Risks. Python GIL contention under heavy batched fan-out; copy overhead through the LocalCPU buffer; cannot influence pin/unpin or admission policy beyond what LMCache calls into the connector.
- Contract surface. A wider Python interface for full storage backends in non-multiprocess mode (see LMCache storage plugins). The plugin owns more of the lifecycle (admission, eviction signaling, lookup) instead of being a passive byte store.
- Performance ceiling. Same Python client floor as Phase 1, but eliminates one buffer hop because the plugin can allocate directly into the storage path rather than through
LocalCPUBackend.allocate. - Complexity. Medium. Surface area is larger, contract is younger and changes more often.
- Operational surface. Same install model, different config path.
- Wins when. A customer needs pin/unpin fidelity, custom admission control, or wants Aerospike to be the primary remote tier with no
LocalCPUBackendround-trip. - Risks. Interface stability; debugging breaks deeper into LMCache; behavioral parity with Phase 1 must be maintained for users who don't want the bigger surface.
- Contract surface. The L2 adapter slot used in multiprocess mode. A pure Python
pluginimplements the fullL2AdapterInterface. Anative_pluginexposes a lower-level pybind/C++ connector with batch get/set/exists/delete andeventfdcompletions. - Performance ceiling.
native_pluginis the highest-throughput path short of full native C++; it bypasses much of the Python overhead on the hot path and integrates with multiprocess worker scheduling. - Complexity. High. Multiprocess setup, IPC, lifecycle ownership, and
eventfdplumbing add real cost. - Operational surface. Couples the connector to LMCache's multiprocess deployment model.
- Wins when. Customers run LMCache in multiprocess mode and want Aerospike to participate as a first-class L2 tier rather than a passive remote.
- Risks. Largest surface area; tightest coupling to LMCache internals; the most fragile across LMCache versions.
- Contract surface. Modeled after LMCache's native Redis/RESP connector (
lmcache/v1/storage_backend/native_clients/resp_client.pyandConnectorBase). Implemented in C++ againstlibaerospike, exposed via pybind11. - Performance ceiling. The highest of the four. Zero-copy writes into LMCache-supplied buffers, no GIL during fetch, asynchronous
as_event_loopevent loop integration. - Complexity. Highest. Build matrix (manylinux wheels), ABI compatibility, debug story, build dependency on
libaerospikedevelopment headers. - Operational surface. Same plugin install model but installs a compiled wheel.
- Wins when. Sustained multi-GB/s per worker is required; measured Python overhead exceeds an acceptable percentage of put/get latency.
- Risks. Long delivery cycle; need to track upstream LMCache
ConnectorBasechanges; cross-platform packaging cost.
| Alternative | Why rejected |
|---|---|
| Aerospike Vector Search (AVS) | LMCache stores binary KV tensors, not embeddings. RAG embedding workloads are upstream of LMCache. |
| Aerospike Graph Service (AGS) | LMCache's data model is hashed token chunks and optional cache-location routing. Lookup tables fit KV/document storage; graph traversal adds no value. |
| Replace Mooncake/NIXL transport with Aerospike | RDMA-class transport between prefill/decode workers is a different problem with different SLAs. Aerospike is correctly positioned as the durable/shared tier, not the transport. |
| Replace LMCache's GPU/CPU tiers | LMCache owns HBM and DRAM tiers. We only target the remote tier. |
| Use Aerospike CDTs (lists/maps) for chunk segments | Segments are large opaque byte blobs accessed sequentially. CDTs add server-side overhead with no traversal benefit; flat segment records are simpler and faster. See [cdt-bounded-collections.md](https://github.com/aerospike/agent-skills/blob/main/skills/aerospike-development/references/cdt-bounded-collections.md). |
| Use Aerospike secondary indexes for prefix lookup | LMCache lookup is by exact CacheEngineKey. Secondary indexes are unnecessary and would harm write throughput. See [query-secondary-index-discipline.md](https://github.com/aerospike/agent-skills/blob/main/skills/aerospike-development/references/query-secondary-index-discipline.md). |
| Embed all segments as a CDT list under one key | Defeats Aerospike's 8 MiB record cap and concentrates load on a single hot record. |
| Customer profile | Recommended phase / surface |
|---|---|
| Evaluating Aerospike as an LMCache remote tier; single-process LMCache workers | Phase 1 |
| Need pin/unpin fidelity or admission control; willing to track LMCache interface churn | Phase 2 (StoragePluginInterface) |
| Running LMCache in multiprocess mode and want first-class L2 participation | Phase 2 (L2AdapterInterface Python) -> Phase 3 (native_plugin) |
| Bytes/s ceiling pushed by 70B+ models at TP>=8; Python overhead measured as the bottleneck | Phase 3 (native C++) |
This section is the build specification. Every decision below is intended to be unambiguous; where there is residual ambiguity it is called out in Section 7.
lmcache-aerospike/
pyproject.toml
README.md
DESIGN.md
src/lmcache_aerospike/
__init__.py
adapter.py # AerospikeConnectorAdapter (ConnectorAdapter)
connector.py # AerospikeRemoteConnector (RemoteConnector)
client.py # AerospikeClientHolder (process-singleton wrapper)
config.py # AerospikeConfig (parsed extra_config)
keys.py # logical CacheEngineKey -> Aerospike meta + segment keys
sharding.py # ChunkShardPlanner (adaptive sizing)
limits.py # server-side record-size discovery + segment-limit reconciliation
serde.py # MemoryObj metadata <-> Aerospike record bins; RemoteMetadata pack/unpack
policies.py # read_policy / write_policy / batch_policy factories
errors.py # aerospike.exception.* -> connector behavior mapping
metrics.py # optional Prometheus hooks (opt-in)
tests/
unit/ # mocks aerospike.Client; no network
integration/ # docker-compose single-node CE, namespace lmcache
bench/ # synthetic chunk stream; pytest-benchmark
docker/
docker-compose.yml # single-node Aerospike CE for integration tests
aerospike.conf # namespace lmcache, nsup-period > 0
Distribution. PyPI package name lmcache-aerospike. Versioning: 0.1.x alpha during Phase 1 bring-up, 0.2.0 first stable Phase 1, 0.3.x Phase 2 alpha. Python compatibility tracks LMCache: >=3.10,<3.14.
Runtime dependencies. aerospike (official Python client, version-pinned — see below), lmcache (peer dependency, pinned to a known-good range, e.g. >=0.4.5,<0.5), and the standard library. No numpy requirement on the connector hot path; torch is already an LMCache dependency and the connector uses it only via MemoryObj.
Aerospike client version pin. Pin to aerospike>=14.0.0,<19.0.0. Two reasons: (1) the modern batch API (batch_read, batch_write with BatchRecords) is present and the legacy exists_many/get_many/select_many methods this design avoids are already gone; (2) meta={"ttl": N} on put/batch Write is still valid (it is deprecated in favor of the write-policy ttl from client 19.1.0). All TTL setting goes through one _apply_ttl helper, so moving the pin to >=19 later is a one-function change.
AerospikeConnectorAdapter inherits from lmcache.v1.storage_backend.connector.ConnectorAdapter. It:
- Has a no-argument
__init__that callssuper().__init__("aerospike://"). This is required: LMCache'sConnectorManager._remote_adapters_plugin_launcherinstantiates the adapter asloaded_class()(no args) whenclass_nameresolves to aConnectorAdaptersubclass. - Overrides
can_parse(url)to accept bothaerospike://...andplugin://aerospike[.{instance}], usingextract_plugin_typefrom the upstream module. For the plugin path the URL LMCache passes isplugin://{plugin_name}(built inRemoteBackend.init_connection). - Implements
create_connector(context: ConnectorContext) -> RemoteConnectorby readingconfig/metadatafromcontext.local_cpu_backend.config/.metadata(the canonical upstream pattern used byRESPConnector/RedisConnector/FSConnector; fall back tocontext.config/context.metadata), building anAerospikeConfigfrom that config pluscontext.plugin_name, obtaining a memoizedAerospikeClientHolder(keyed by(hosts, namespace, tls_name)), and returning anAerospikeRemoteConnector. The connector's__init__runs server-side limit discovery before returning (see Section 4.4.0).
AerospikeRemoteConnector inherits from lmcache.v1.storage_backend.connector.base_connector.RemoteConnector. The constructor:
__init__(
self,
config: LMCacheEngineConfig,
metadata: LMCacheMetadata,
local_cpu_backend: LocalCPUBackend,
loop: asyncio.AbstractEventLoop,
aerospike_config: AerospikeConfig,
client_holder: AerospikeClientHolder,
)
calls super().__init__(config, metadata) first (this initializes self.save_chunk_meta, self.meta_shapes, self.meta_dtypes, self.meta_fmt, self.full_chunk_size_bytes, self.single_token_size, self.remote_metadata_bytes from base_connector.py), then stores its dependencies. It implements every abstract method from the base class and overrides every support_* predicate that the implementation supports.
AerospikeClientHolder is the only place that constructs aerospike.client(...). It is keyed by a tuple of (hosts, namespace, tls_name) and reference-counted: each AerospikeRemoteConnector increments on construction and decrements on close(); the underlying aerospike.Client is destroyed only when the count reaches zero. This makes per-process singleton behavior the default ([client-singleton.md](https://github.com/aerospike/agent-skills/blob/main/skills/aerospike-development/references/client-singleton.md)) while still supporting multiple plugin instances (aerospike.primary, aerospike.backup) talking to distinct clusters.
The official aerospike Python client is synchronous (a C extension wrapping libaerospike). To preserve LMCache's async contract:
- A bounded
concurrent.futures.ThreadPoolExecutoris created perAerospikeRemoteConnectorwithmax_workers = aerospike_config.executor_threads(default 16). - Every blocking call is dispatched via
loop.run_in_executor(self._executor, callable, *args). - A priority scheduler modeled after the Redis connector's
AsyncPQExecutor(PEEK,PREFETCH,GET,PUT; see[redis_connector.py](https://github.com/LMCache/LMCache/blob/dev/lmcache/v1/storage_backend/connector/redis_connector.py)) wraps submissions so that prefetch traffic does not starve user-facinggetcalls. Phase 1 implementation uses anasyncio.PriorityQueueplus a worker task pool feeding the executor.
The data model has two record families per logical LMCache key. The logical key is the CacheEngineKey.to_string() value (or LayerCacheEngineKey.to_string() for layerwise mode).
- Aerospike key:
(namespace, set, "{logical_key}|m"). - Bins:
| Bin | Type | Purpose |
|---|---|---|
ver |
u8 / int | Schema version of this meta record (starts at 1) |
state |
str | One of ready, partial, tombstone |
nseg |
u16 / int | Number of segment records that hold the payload |
seg_b |
u32 / int | Bytes per segment (last segment may be shorter; see tot_b) |
tot_b |
u64 / int | Total payload bytes across all segments |
md |
bytes | Serialized RemoteMetadata (length, per-group shapes/dtypes, fmt) produced by RemoteMetadata.serialize() in [protocol.py](https://github.com/LMCache/LMCache/blob/dev/lmcache/v1/protocol.py). Present iff self.save_chunk_meta is true (default true; LMCache forces it true in layerwise mode). One opaque blob (size self.remote_metadata_bytes) instead of separate shape/dtype bins, so it supports num_groups > 1 and is byte-compatible with the deserializer. When absent, reads allocate from the connector's fixed full-chunk metadata (self.meta_shapes/meta_dtypes/meta_fmt) and call reshape_partial_chunk. See Section 4.4.3 |
serde |
str | Informational only. remote_serde (naive/cachegen/kivi) is applied by LMCache's RemoteBackend above this connector (the connector is serde-agnostic and stores opaque bytes). Recorded for ops triage; never used to (de)serialize |
crc32 |
u32 / int | CRC32 over concatenated segments; present iff enable_crc32 is true |
created_at |
i64 / int | Epoch seconds at successful put |
ttl_class |
str | Optional caller-supplied class (e.g. session, corpus) for ops triage |
pin |
bool | True iff this key was pinned (TTL forced to never-expire) |
b |
bytes | Inline payload bin; only present when nseg == 1 (the single-record fast path) |
When nseg == 1 the meta record itself carries the payload in bin b. When nseg > 1 the b bin is absent and segment records hold the payload.
- Aerospike key:
(namespace, set, "{logical_key}|s|{i}")foriin[0, nseg). - Bins:
| Bin | Type | Purpose |
|---|---|---|
b |
bytes | Segment payload (length seg_b, except the last segment which is tot_b - (nseg - 1) * seg_b) |
crc32 |
u32 / int | Per-segment CRC32; present iff enable_crc32 is true |
There is intentionally only one bin in the common case so wire format is minimal.
keys.py defines:
def meta_key(ns: str, set_: str, ck: CacheEngineKey | LayerCacheEngineKey) -> aerospike_key
def segment_key(ns: str, set_: str, ck: ..., i: int) -> aerospike_key
def segment_keys(ns: str, set_: str, ck: ..., nseg: int) -> list[aerospike_key]
User keys passed to Aerospike are byte strings derived from ck.to_string() with the |m or |s|{i} suffix. The Aerospike default of "store the digest, not the key" still applies; policy.send_key defaults to False (see [policy-send-key.md](https://github.com/aerospike/agent-skills/blob/main/skills/aerospike-development/references/policy-send-key.md)) because the logical key is already fully encoded in the digest input and storing it again wastes record space.
ChunkShardPlanner is a pure function (no I/O) that decides how to split a payload, driven by limits the connector discovered from the server at startup (see Section 4.3.6).
Inputs:
payload_bytes: int(length ofmemory_obj.byte_array)target_segment_bytes: int- the preferred segment size. Default 4 MiB =4 * 1024 * 1024. Automatically clamped down at startup if the server-side cap is lower.max_segment_bytes: int- the hard ceiling per segment. Derived from the server, not hardcoded: see Section 4.3.6. Operator override viaextra_configis allowed but must stay at or below the server value.min_segment_bytes: int- the lower bound below which sharding is not worth the extra meta/segment round trips. Default64 KiB=64 * 1024. (This is a sharding-overhead floor, not the Aerospike ops sweet spot, which is the smaller 1-10 KiB band; see Section 2.2.)single_record_threshold_bytes: int- the inclusive cutoff for the single-record fast path. Defaultmin(target_segment_bytes, max_segment_bytes).
Decision rule:
- If
payload_bytes <= single_record_threshold_bytesandpayload_bytes <= max_segment_bytes: return(nseg=1, seg_b=payload_bytes). Single-record fast path; meta record holds the payload in binb. - Else, compute
nseg = ceil(payload_bytes / target_segment_bytes)andseg_b = ceil(payload_bytes / nseg). This balances segments (no tiny tail) and guaranteesseg_b <= target_segment_bytes. If the result hasseg_b > max_segment_bytes(only possible when an operator override invalidates the relationship), raiseAerospikeConfigError. - Enforce
seg_b >= min_segment_bytesonly as a warning: if a payload is small enough that all segments would land belowmin_segment_bytes, the planner falls back to rule 1 (single-record path) regardless of the threshold.
The intent: the ~4 MiB band is the LMCache authors' documented chunk-transfer sweet spot between per-transfer/round-trip overhead and transfer time. The LMCache paper ("LMCache: An Efficient KV Cache Layer for Enterprise-Scale LLM Inference", arXiv:2510.09665, Section 4.1 "Batched Operations" and Section 7 transfer-granularity evaluation) shows that page-level KB transfers underutilize bandwidth and that MB-scale chunks are required to saturate PCIe/network links; LMCache therefore aggregates many small pages into larger configurable chunks. This is byte-throughput guidance and is distinct from Aerospike's ops-throughput 1-10 KiB record sweet spot (see Section 2.2); the connector intentionally follows the LMCache value. The server's actual configured record-size cap is respected as the hard ceiling and clamps target_segment_bytes down if it is lower (e.g. the Aerospike 7.1+ max-record-size default of 1 MiB). Sharding kicks in for anything that does not fit a single record cleanly.
Diagram:
flowchart LR
put["put key memory_obj"] --> planner["ChunkShardPlanner"]
planner -->|"payload <= 4 MiB"| single["single record meta plus bytes"]
planner -->|"payload > 4 MiB"| multi["N segment records plus meta"]
single --> writeMeta["aerospike.put meta with b"]
multi --> writeSegs["batch_write segments"]
writeSegs --> writeMetaReady["aerospike.put meta state=ready"]
LMCache writes are idempotent per chunk_hash: two writers for the same CacheEngineKey may race, but both produce the same payload bytes for the same key (the chunk hash is content-addressed). The protocol exploits this:
- Multi-segment put:
- Write all segment records via
Client.batch_write(or sequentialClient.putif batch_write is unavailable in the chosen client version), each with the configured TTL. - Write the meta record last with bins set to
state="ready", the finalnseg,tot_b,seg_b,ver, and the rest of the metadata bins. Ifenable_crc32, includecrc32over the concatenated payload. - Single-record put:
- Write the meta record with inline bin
bandstate="ready"in oneClient.put. No segments. - Reader:
- Read the meta record. If absent, return miss.
- If
state != "ready", return miss (treat as partial / in-flight). - If
nseg == 1, read binbfrom the meta record; assembleMemoryObj. - If
nseg > 1, build the segment key list and issue oneClient.batch_read. If any segment is missing or returns a partial read, log a WARNING and return miss. Concatenate segments in order; verifytot_b; ifenable_crc32, verify CRC. - Overwrite: bump
ver. Readers that race a write see either fully old or fully new bytes (because the meta record commits last withstate="ready"); they never see a mix. - Generation/CAS (
policy-generation-cas.md) is intentionally not used because writes for the sameCacheEngineKeyare idempotent in LMCache's model. CAS would add round trips without correctness benefit. This is a deliberate design choice and is reverted only if a future workload demonstrates a need. - Crash mid-write: segments may exist without a
readymeta. Two recovery paths:
- Passive (default): the meta TTL (and segment TTLs) will expire the records via NSUP. No background sweep needed.
- Active (opt-in via
extra_config.aerospike.enable_repair_scan, default false): a periodic scan can identify orphan segments. Phase 1 ships the passive path only.
max_segment_bytes is not hardcoded. The connector queries the live Aerospike cluster at startup and derives the cap from what the namespace actually allows. This means the same connector binary works against Aerospike 6.x, 7.0.x, and 7.1+ deployments without manual tuning, and it picks up operator changes to write-block-size / flush-size / max-record-size automatically on the next restart.
Probe placement (⚠ corrected from an earlier draft). Discovery runs during connector construction (inside create_connector / AerospikeRemoteConnector.__init__), guarded by a self._limits_ready once-flag — not in post_init(). This is a deliberate correction: upstream RemoteBackend.init_connection() calls CreateConnector(...) and stores the wrapped connector but never calls connector.post_init() (verified across [remote_backend.py](https://github.com/LMCache/LMCache/blob/dev/lmcache/v1/storage_backend/remote_backend.py), [storage_manager.py](https://github.com/LMCache/LMCache/blob/dev/lmcache/v1/storage_backend/storage_manager.py), and the storage-backend [__init__.py](https://github.com/LMCache/LMCache/blob/dev/lmcache/v1/storage_backend/__init__.py) CreateStorageBackends). A probe placed only in post_init would silently never run and max_segment_bytes would be left unset, crashing the first put. We still provide a post_init override that triggers the same idempotent discovery in case a future LMCache release calls it, but correctness does not depend on it. The probe performs:
Client.info_random_node(f"namespace/{namespace}")to fetch the namespace's config + stats as a semicolon-separatedkey=valuestring (this form is used by the official client'sttl.pyexample and is robust across server versions;get-config:context=namespace;id={namespace}also works). Strip any leadingrequest\tprefix from the info response before parsing.- Parse three fields, preferring the most specific limit available (server-version-aware):
max-record-size(Aerospike 7.1+ only) - the explicit per-record cap. Default1Min 7.1+; configurable up to8M. If present and non-zero, this is the cap.write-block-size(Aerospike <=7.0) - the streaming-write-block size, which is also the implicit max record size on those versions. Allowed values are powers of 2 from 128 KiB to 8 MiB.flush-size(Aerospike 7.1+) - the I/O unit size. Informational only; the SWB is hard-coded to 8 MiB in 7.1+ andmax-record-sizeis the real cap.
- Compute
server_max_record_bytesfrom those fields. - Compute
effective_max_segment_bytes = server_max_record_bytes - SAFETY_MARGIN_BYTES, whereSAFETY_MARGIN_BYTES = 65536(64 KiB) leaves room for the meta bins (ver,state, shape, dtype, etc.) on the single-record fast path, where a payload bin lives alongside metadata in the same record. - Reconcile with operator config:
- If
extra_config.aerospike.max_segment_bytesis unset, useeffective_max_segment_bytes. - If the operator override is
<= effective_max_segment_bytes, accept it. - If the operator override is
> effective_max_segment_bytes, log WARNING and clamp toeffective_max_segment_bytes(rather than failing). Operator intent is preserved as much as possible; cluster correctness is not.
- If
- Reconcile
target_segment_bytessimilarly: if the configured target exceedseffective_max_segment_bytes, log WARNING and clamptarget_segment_bytes = effective_max_segment_bytes. - Recompute
single_record_threshold_bytes = min(configured_single_record_threshold_bytes, effective_max_segment_bytes). - TTL/NSUP precondition (fail fast). The same info response carries
nsup-period. Ifdefault_ttl_seconds > 0and the namespace reportsnsup-period 0(NSUP disabled), raiseAerospikeTTLConfigErrorimmediately with an actionable message pointing at the namespace config. This surfaces the misconfiguration at startup instead of as a crypticAEROSPIKE_ERR_FAIL_FORBIDDEN(code 22) on the first write.
Logging. On every connector startup, the connector emits one INFO line per discovered limit so operators can confirm the cluster's view of itself matches their expectation:
INFO lmcache_aerospike.connector Aerospike namespace 'lmcache' record-size limits discovered:
INFO lmcache_aerospike.connector server: max-record-size=4194304, write-block-size=N/A, flush-size=131072
INFO lmcache_aerospike.connector derived: max_segment_bytes=4128768 (server cap minus 64 KiB margin)
INFO lmcache_aerospike.connector effective: target_segment_bytes=4128768, single_record_threshold_bytes=4128768, min_segment_bytes=65536
If the operator's configured target_segment_bytes was clamped, that line is logged at WARNING with the original and clamped values.
Failure modes.
- If the
infocall fails (e.g. the namespace name is wrong, or the client cannot reach any node), startup fails fast withAerospikeNamespaceProbeErrorcontaining the actual server response. No fallback to a guessed cap. - If the parsed value is zero, missing, or out of the valid range (128 KiB - 8 MiB), startup fails fast with
AerospikeServerLimitError. We never silently fall back to a guessed cap because doing so risksRecordTooBigexceptions in production. - The probe is per-namespace; multiple plugin instances each probe their own namespace.
Refresh. Limits are read once at startup. Operator changes to max-record-size / write-block-size require a connector restart to take effect. A future enhancement (out of Phase 1 scope) may add periodic re-probing.
Tests.
- Unit: parser correctness for sample
get-configresponses from 7.1+ (withmax-record-size) and 7.0 (with onlywrite-block-size). - Integration: change
max-record-sizein the test container, restart the connector, assert the new cap is reflected in the startup log and that puts above it now shard differently.
Each subsection mirrors the corresponding method in base_connector.py. Signatures below are verbatim from the upstream dev branch.
Two cross-cutting facts that constrain every method (verified against upstream dev):
- The connector is serde-agnostic. LMCache's
RemoteBackendappliesremote_serde(naive/cachegen/kivi) above the connector: it callsserializer.serialize(memory_obj)beforeconnector.putanddeserializer.deserialize(...)afterconnector.get(see[remote_backend.py](https://github.com/LMCache/LMCache/blob/dev/lmcache/v1/storage_backend/remote_backend.py)). The connector therefore stores opaque bytes and must, on read, return aMemoryObjshaped so the deserializer can consume it. It never compresses or interprets payloads. This is exactly theFSConnectorcontract ([fs_connector.py](https://github.com/LMCache/LMCache/blob/dev/lmcache/v1/storage_backend/connector/fs_connector.py)). - MLA / layerwise key handling happens above the connector.
RemoteBackendrewrites keys toworker_id 0inremote_enable_mla_worker_id_as0mode before calling the connector, and layerwise mode producesLayerCacheEngineKeystrings. The connector treats both uniformly viakey.to_string()and needs no special path; layerwise mode does, however, forceself.save_chunk_meta = True, so themdbin (see Section 4.3.1) is always written in that mode.
- Purpose: run the server-side record-size discovery described in Section 4.3.6 before any user request is served.
- Where it runs (⚠ corrected): in
AerospikeRemoteConnector.__init__(invoked from the adapter'screate_connector), via a private_ensure_limits()guarded by aself._limits_readyonce-flag. It does not rely onpost_init()being called, because upstreamRemoteBackendnever callspost_init()on a remote connector (see Section 4.3.6). Apost_init(self)override is still provided and simply callsself._ensure_limits()(idempotent), so the connector remains correct whether or not LMCache ever calls it. - Implementation: invoke
Client.info_random_node("namespace/" + namespace), parsemax-record-size(preferred) /write-block-size/flush-size, computeeffective_max_segment_byteswith the 64 KiB safety margin, reconcile againstextra_configoverrides (clamping with WARNING when needed), check the TTL/NSUP precondition, and persist the resolved limits onself. - Logging: emit the INFO lines shown in Section 4.3.6. A clamped target produces a WARNING.
- Failure: raise
AerospikeNamespaceProbeError,AerospikeServerLimitError, orAerospikeTTLConfigErrorper the error matrix. Do not fall back to a guessed cap. A construction-time raise propagates toRemoteBackend.init_connection, which logs it and retries per itsmin_reconnect_interval— i.e. fail loud, never silently. - Idempotent: safe to call multiple times (e.g. on reconnect /
recreate_backend); subsequent calls are no-ops.
- Purpose: True iff a fully-committed entry exists for
key. - Aerospike ops:
Client.exists(meta_key)returning(key, meta); ifmetais None, return False. Else also fetch only thestatebin viaClient.select(meta_key, ["state"])(single round trip if we useselectdirectly). - Optimization: in practice
Client.select(meta_key, ["state"])is one round trip and tells us both existence and state; prefer it. - Policy:
read_policywithtotal_timeout = aerospike_config.read_timeout_ms, replicaMASTER_PROLES(sequential failover),key=POLICY_KEY_DIGEST,send_set_name=False. - Failure mapping:
aerospike.exception.RecordNotFound-> return False;aerospike.exception.TimeoutError-> log WARNING, raise; other client errors raise after mapping inerrors.py. - Concurrency:
PEEKpriority, dispatched via the executor. - Tests: unit (mocked client, present/absent/partial); integration (round-trip after
put).
- Implementation: same logic as
exists, but calls the synchronous Aerospike client directly on the calling thread. No executor dispatch (the caller is already synchronous). - Caveat: must remain thread-safe because LMCache may call this from background threads.
-
Purpose: Return the
MemoryObjforkey, orNoneon miss. -
Read meta + verify:
Client.get(meta_key)->(key, meta, bins); onRecordNotFoundreturn None; ifbins["state"] != "ready"return None (treat partial/in-flight as miss). -
Allocate the receive buffer (honor
save_chunk_meta, ⚠ corrected to match[fs_connector.py](https://github.com/LMCache/LMCache/blob/dev/lmcache/v1/storage_backend/connector/fs_connector.py)):- if
self.save_chunk_metais true:rm = RemoteMetadata.deserialize(bins["md"]);memory_obj = self.local_cpu_backend.allocate(rm.shapes, rm.dtypes, rm.fmt). Do not callreshape_partial_chunk— the stored shapes already encode the true (possibly partial) size, includingnum_groups > 1. - else (
save_chunk_metafalse):memory_obj = self.local_cpu_backend.allocate(self.meta_shapes, self.meta_dtypes, self.meta_fmt); after the payload is filled, callself.reshape_partial_chunk(memory_obj, bytes_read)(wherebytes_readis a multiple ofself.single_token_size) so the returnedMemoryObjhas the correct shape.
Allocation must go through
self.local_cpu_backend.allocateso LMCache's memory bookkeeping (refcount, pin) stays accurate. If it returns None (CPU backend full), return None and let LMCache decide whether to retry. The earlier draft's "always allocate viaself.meta_shapes" was wrong: it breakscachegen/kiviserde (variable serialized size) andnum_groups > 1. - if
-
Payload read, single-record path (
nseg == 1): copybins["b"]intomemory_obj.byte_array. -
Payload read, multi-segment path (
nseg > 1): build the segment key list ([segment_key(..., i) for i in range(nseg)]) and issue oneClient.batch_read(segment_keys, ["b"]); walkbrs.batch_recordsin order, verifyingrec.result == 0andrec.record is not None, and concatenate thebbins in order intomemory_obj.byte_array. -
Partial-read handling: if any segment entry is missing or has a nonzero per-record result, log WARNING ("orphan or in-flight write"), release the
MemoryObj(ref_count_down()), and return None. Do not raise. -
Optional CRC: if
enable_crc32, compute CRC32 over the assembled bytes and compare tometa["crc32"]; mismatch -> log ERROR, release theMemoryObj, return None. -
Policy: read policy with
replica=POLICY_REPLICA_SEQUENCEfor failover;socket_timeoutandtotal_timeoutfrom config. -
Failure mapping:
RecordNotFound-> None;TimeoutError-> log + None (treat as miss); other errors mapped inerrors.pyand raised. -
Concurrency:
GETpriority. -
Tests: unit (single-record path, multi-segment path, missing segment, CRC mismatch, partial chunk reshape); integration (matrix of payload sizes).
- Purpose: Store
memory_obj.byte_arrayunderkey. - Pipeline:
-
Acquire
memoryviewofmemory_obj.byte_array. -
plan = ChunkShardPlanner.plan(len(view), aerospike_config). -
Build the meta bins. When
self.save_chunk_metais true, includemd = RemoteMetadata(len(view), memory_obj.get_shapes(), memory_obj.get_dtypes(), memory_obj.get_memory_format()).serialize()(one opaque blob; mirrors[fs_connector.py](https://github.com/LMCache/LMCache/blob/dev/lmcache/v1/storage_backend/connector/fs_connector.py)). When false, omitmd. Build shape/dtype/fmt frommemory_obj.get_*(), not fromself.meta_shapes(the serialized object may be a compressed/binarycachegen/kivirepresentation whose shape differs from the full chunk). -
If
plan.nseg == 1: oneClient.put(meta_key, bins | {"b": bytes(view)}, meta=<ttl meta>, policy=<write_policy>). -
Else (⚠ corrected to the real batch API): build a
BatchRecordsof per-segmentWriteops and commit the meta record last:from aerospike_helpers.batch import records as br from aerospike_helpers.operations import operations as op writes = [ br.Write( key=segment_key(ns, set_, ck, i), ops=[op.write("b", bytes(view[i*seg_b:(i+1)*seg_b]))], meta=ttl_meta, policy=write_policy, ) for i in range(plan.nseg) ] batch = br.BatchRecords(writes) client.batch_write(batch) for rec in batch.batch_records: # inspect per-key results if rec.result != 0: raise <mapped error> # top-level success != per-key success client.put(meta_key, bins | {"state": "ready"}, meta=ttl_meta, policy=write_policy)
Client.batch_writetakes a singleBatchRecordsobject built fromaerospike_helpers.batch.records— not a list of(key, bins, meta)tuples. Per-key results must be inspected ([batch-parallel-key-operations.md](https://github.com/aerospike/agent-skills/blob/main/skills/aerospike-development/references/batch-parallel-key-operations.md)).
-
- TTL (⚠ set in one place):
ttl_for(key)returns-1if pinned, elseaerospike_config.default_ttl_seconds(0= namespacedefault-ttl,-2= don't update void-time). The actual mechanism is centralized in a single_apply_ttlhelper becausemeta={"ttl": N}is valid only on Aerospike Python client< 19.1.0; from19.1.0TTL moves to the write policy. The connector pins the client version (see Section 4.1) and the helper is the only code that touches the version-sensitive API. - Memory: after
putreturns, LMCache decrements theMemoryObjrefcount; the connector must not retain references past return. - Policy:
write_policywithkey=POLICY_KEY_DIGEST,commit_level=POLICY_COMMIT_LEVEL_ALL(CE default; configurable down toMASTERviaextra_config);exists=POLICY_EXISTS_IGNORE(overwrite-always);gen=POLICY_GEN_IGNORE(no CAS). - Failure mapping:
RecordTooBig-> raiseAerospikeRecordTooBigErrorwith explicit guidance (lowertarget_segment_bytesormax_segment_bytes).TimeoutError-> log + raise (caller decides retry). Other errors raised. - Concurrency:
PUTpriority. - Tests: unit (single-record put, multi-segment put with mocked batch_write, oversized payload raises); integration (round-trip; verify TTL via
Client.existsafter expiry).
4.4.5 def support_batched_get(self) -> bool -> True and async def batched_get(self, keys: List[CacheEngineKey]) -> List[Optional[MemoryObj]]
- Purpose: Parallel
getfor many keys. - Implementation: bounded
asyncio.Semaphore(aerospike_config.batch_max_in_flight); gatherself.get(k)for each key under the semaphore; return results in the same order. Eachgetis independent so existing single-key logic applies. - No coalescing required: LMCache passes distinct keys (per
[batch-parallel-key-operations.md](https://github.com/aerospike/agent-skills/blob/main/skills/aerospike-development/references/batch-parallel-key-operations.md), the connector still de-duplicates defensively before issuing requests).
4.4.6 def support_batched_put(self) -> bool -> True and async def batched_put(self, keys, memory_objs)
- Symmetric to
batched_get. Eachputindependent; bounded by the same semaphore.
4.4.7 def support_batched_contains(self) -> bool -> True and def batched_contains(self, keys: List[CacheEngineKey]) -> int
- Purpose: Consecutive-prefix-length count. This is the critical LMCache prefetch primitive.
- Implementation (⚠ corrected —
exists_manyis removed in current clients): synchronousbrs = Client.batch_read([meta_key(k) for k in keys], []). Passing an empty bin list returns metadata only (eachBatchRecord.recordis a(key, meta)2-tuple). Iteratebrs.batch_recordsin original key order; count consecutive entries withrec.result == 0(record found) and return the count at the first miss.Client.exists_many/get_many/select_manywere removed from the official Python client (present in 7.0.x, gone in current releases);batch_readis the version-stable replacement. - State corner case:
batch_readwithbins=[]returns nostatebin. Phase 1 treats any existing meta record asreadybecause the atomicity protocol writes meta last (withstate="ready"). An in-flight writer can briefly causecontainstrue /getmiss; LMCache already handles miss-after-contain by re-prefilling. - Coalescing: the consecutive-prefix semantics operate on the original order, so do not reorder; you may still dedupe identical adjacent keys defensively per
[batch-parallel-key-operations.md](https://github.com/aerospike/agent-skills/blob/main/skills/aerospike-development/references/batch-parallel-key-operations.md).
4.4.8 def support_batched_async_contains(self) -> bool -> True and async def batched_async_contains(self, lookup_id, keys, pin=False) -> int
- Implementation: dispatch the sync
Client.batch_read([meta_key(k) for k in keys], [])to the executor withPREFETCHpriority (⚠exists_manyis removed in current clients — see Section 4.4.7); same consecutive-prefix semantics asbatched_contains. **pinargument:** Phase 1 ignores it (the upstream FS connector also ignores it). A future Phase enhancement may bump TTL viaClient.touchon hits whenpin=True.
4.4.9 def support_batched_get_non_blocking(self) -> bool -> True and async def batched_get_non_blocking(self, lookup_id, keys) -> List[MemoryObj]
- Implementation:
asyncio.gather(*(self.get(k) for k in keys), return_exceptions=True). Walk results in order: appendMemoryObjwhile consecutive hits; on firstNoneorException, stop appending and release every subsequent successfully-fetchedMemoryObjviaresult.ref_count_down()to avoid leaks (verbatim contract frombase_connector.py). - Concurrency: each underlying
getruns atPREFETCHpriority.
- Purpose: Synchronous delete.
- Pipeline: read
nsegfirst (Client.select(meta_key, ["nseg"])), thenClient.remove(meta_key)(so future reads miss immediately). Then, ifnseg > 1, best-effort segment deletes viaClient.batch_write(br.BatchRecords([br.Remove(key=segment_key(..., i)) for i in range(nseg)])); failures here are logged at WARNING and ignored because TTL will clean up. (batch_remove(keys)is also acceptable; useBatchRecords([Remove(...)])for symmetry with the write path.) - CE constraint:
durable_delete=Trueis EE-only. Phase 1 calls regular delete. Document that deleted records may resurrect on cold restart in CE; this is acceptable for cache data. - Return: True on meta deletion success, False on
RecordNotFound.
- Default: return
[]and log INFO "list disabled by default (set extra_config.aerospike.enable_list=true to enable expensive scan)". Mirrors the stub posture in upstream connectors wherelistis not in the hot path. - When enabled: issue an Aerospike
Client.scan(namespace, set_).select(["state"]); yieldkey.to_string()for each meta record where the user-key suffix is|m. This is expensive and intended only for debugging / migration. - Policy: scan policy with a low priority and a configurable record-rate limit.
- Implementation:
Client.is_connected()plusClient.get_node_names(). Return0on success; non-zero (e.g.1) on any exception or if no nodes are reachable.
- Pipeline: shut down the
AsyncPQExecutor-equivalent; shut down theThreadPoolExecutorwithwait=True; decrement the client holder's ref count; if it hit zero, callclient.close(). - Idempotent: safe to call multiple times.
The user writes the following in their LMCache config (verbatim from the upstream plugin loader, fields documented in Section 4.5.2):
chunk_size: 256
local_cpu: true
max_local_cpu_size: 20
remote_storage_plugins: ["aerospike"]
extra_config:
remote_storage_plugin.aerospike.module_path: lmcache_aerospike.adapter
remote_storage_plugin.aerospike.class_name: AerospikeConnectorAdapter
remote_storage_plugin.aerospike.hosts: "aerospike-0:3000,aerospike-1:3000"
remote_storage_plugin.aerospike.namespace: lmcache
remote_storage_plugin.aerospike.set: kv_chunks
remote_storage_plugin.aerospike.target_segment_bytes: 4194304
# max_segment_bytes is discovered from the server at startup (see Section 4.3.6).
# Uncomment to override; the override is clamped to the server's cap.
# remote_storage_plugin.aerospike.max_segment_bytes: 4128768
remote_storage_plugin.aerospike.min_segment_bytes: 65536
# single_record_threshold_bytes defaults to min(target_segment_bytes, max_segment_bytes).
# remote_storage_plugin.aerospike.single_record_threshold_bytes: 4194304
remote_storage_plugin.aerospike.default_ttl_seconds: 86400
remote_storage_plugin.aerospike.read_timeout_ms: 1000
remote_storage_plugin.aerospike.write_timeout_ms: 2000
remote_storage_plugin.aerospike.batch_max_in_flight: 64
remote_storage_plugin.aerospike.executor_threads: 16
remote_storage_plugin.aerospike.enable_list: false
remote_storage_plugin.aerospike.enable_crc32: false
remote_storage_plugin.aerospike.enable_repair_scan: false
remote_storage_plugin.aerospike.commit_level: all
remote_storage_plugin.aerospike.replica: sequenceMultiple instances are supported via the {type}.{instance} plugin naming convention:
remote_storage_plugins: ["aerospike.primary", "aerospike.dr"]
extra_config:
remote_storage_plugin.aerospike.primary.module_path: lmcache_aerospike.adapter
remote_storage_plugin.aerospike.primary.class_name: AerospikeConnectorAdapter
remote_storage_plugin.aerospike.primary.hosts: "as-primary:3000"
remote_storage_plugin.aerospike.primary.namespace: lmcache
remote_storage_plugin.aerospike.dr.module_path: lmcache_aerospike.adapter
remote_storage_plugin.aerospike.dr.class_name: AerospikeConnectorAdapter
remote_storage_plugin.aerospike.dr.hosts: "as-dr:3000"
remote_storage_plugin.aerospike.dr.namespace: lmcacheKey (relative to remote_storage_plugin.{plugin_name}.) |
Type | Default | Effect |
|---|---|---|---|
module_path |
str | (required) | Python module that exports the adapter class |
class_name |
str | (required) | Adapter class name; must subclass ConnectorAdapter |
hosts |
str | (required) | Comma-separated host:port list for cluster seeds |
namespace |
str | lmcache |
Aerospike namespace |
set |
str | kv_chunks |
Aerospike set inside the namespace |
target_segment_bytes |
int | 4194304 (4 MiB) | Preferred segment size on the multi-segment path. Clamped down at startup if the server's cap is lower (see Section 4.3.6) |
max_segment_bytes |
int | discovered | Hard ceiling per segment. Derived from the server's max-record-size (7.1+) or write-block-size (<=7.0) minus a 64 KiB safety margin. Operator override is allowed but is clamped to the server-derived value |
min_segment_bytes |
int | 65536 (64 KiB) | Lower bound; payloads where every segment would be smaller fall back to the single-record path |
single_record_threshold_bytes |
int | min(target_segment_bytes, max_segment_bytes) |
Inclusive ceiling for the single-record fast path. Defaulted from the discovered cap |
default_ttl_seconds |
int | 86400 (1 day) | TTL for new records; 0 means "namespace default-ttl", -1 means never expire (requires namespace allows it) |
read_timeout_ms |
int | 1000 | read_policy.total_timeout |
write_timeout_ms |
int | 2000 | write_policy.total_timeout |
batch_max_in_flight |
int | 64 | Semaphore bound for batched_get / batched_put |
executor_threads |
int | 16 | ThreadPoolExecutor size |
enable_list |
bool | false | If true, list() performs a full set scan; otherwise returns [] |
enable_crc32 |
bool | false | If true, compute and verify per-payload CRC32 |
enable_repair_scan |
bool | false | Reserved; Phase 1 ignores. Phase 2+ may use to sweep orphan segments |
commit_level |
str | all |
One of all or master; maps to Aerospike POLICY_COMMIT_LEVEL_* |
replica |
str | sequence |
One of master, any, sequence, prefer_rack; maps to POLICY_REPLICA_* |
username |
str | "" | Optional; only used for EE auth (unused in Phase 1 CE-only) |
password |
str | "" | Optional; only used for EE auth (unused in Phase 1 CE-only) |
tls_name |
str | "" | Optional; reserved for EE TLS (unused in Phase 1) |
Validation runs in AerospikeConfig.from_extra_config(...) at adapter construction; invalid values raise AerospikeConfigError with a message identifying the offending key.
These rules come from ~/github/agent-skills/skills/aerospike-development/references/ and are not optional; reviewers should expect to see them enforced in code review.
- Singleton client per process (
client-singleton.md). Oneaerospike.Clientper(hosts, namespace, tls_name)triple, ref-counted byAerospikeClientHolder. Per-request client creation is a defect. - One key per batch entry (
batch-parallel-key-operations.md). Dedupe inputs on the client; never repeat the same key. Multi-operation per key (rare in this connector) goes throughoperaterather than duplicate batch entries. - Inspect per-key batch results. Batch APIs may report top-level success while individual entries fail (
KEY_BUSY,RECORD_NOT_FOUND, generation, policy errors). The connector walks every entry and reports per-entry status. - TTL alignment (
single-ttl-nsup-default-ttl.md). Whendefault_ttl_seconds > 0the Aerospike namespace must havensup-period > 0. Otherwise the first write fails withAEROSPIKE_ERR_FAIL_FORBIDDEN(code 22). The connector detects this on first put and surfaces an actionable error pointing at the namespace config. Pinned keys use TTL-1(and require the namespace to allow it). - Send-key off by default (
policy-send-key.md). The logical key is fully encoded into the digest input; storing it again wastes record space. Override withextra_config.aerospike.send_key=trueif ops want it for debugging. - Record sizing trade-off (
model-record-size-hardware-efficiency.md). The Aerospike sweet spot is 1-10 KiB; we deliberately target 4 MiB segments because LMCache bytes/s dominates ops/s. This consciously trades index-RAM efficiency for fewer round trips on the hot KV path. Capacity planning must include device throughput (not just IOPS) forpayload_bytes_per_chunk * chunks_per_second. If a deployment is device-saturated, the fallback tuning recipe is:- Halve
target_segment_bytesto 2 MiB; observe throughput. - If still saturated, halve again to 1 MiB.
- Never go below
min_segment_bytes(default 64 KiB); below that, index RAM becomes the bottleneck.
- Halve
- Server-driven hard ceiling (Section 4.3.6). The connector queries the namespace's
max-record-size(Aerospike 7.1+) orwrite-block-size(Aerospike <=7.0) at startup, deriveseffective_max_segment_bytes, and logs the discovered values. Operators do not setmax_segment_bytesto "stay safely under 8 MiB" by hand; the framework picks the right cap for the actual cluster. Operator overrides are still allowed for benchmarking but are clamped to the server value with a WARNING. - No CDTs for segment data (
cdt-bounded-collections.md). Flat records with onebbin per segment outperform a single record with a CDT list of bytes blobs, and avoid the 8 MiB cap problem. - No secondary indexes (
query-secondary-index-discipline.md). All access is by exact primary key. - CE-only constraints (explicit).
durable_delete=Trueis EE-only; Phase 1 uses regular delete. Cache resurrection on cold restart is acceptable for KV-cache content.- Strong Consistency (SC) namespace mode is EE-only; Phase 1 assumes AP namespaces and tolerates the rare partition window with the existing atomicity protocol (meta-last write).
- On-disk compression is EE-only; the connector does not rely on it. Optionally, payload-side compression can be layered above LMCache (e.g. CacheGen) and is encoded as
serde="cachegen"in the meta record. - TLS-required client auth is EE-only in the official deployment posture; Phase 1 leaves the
tls_nameconfig plumbed but inert. - XDR cross-datacenter replication is EE-only; Phase 1 customers needing cross-region cache sharing get two plugin instances (
aerospike.primaryandaerospike.dr) and write to both; this is documented but not productized in Phase 1.
errors.py centralizes the mapping. The table below is the contract.
aerospike.exception.* |
Connector behavior | Retry? | Observability |
|---|---|---|---|
RecordNotFound |
get -> None; exists/exists_sync -> False; remove_sync -> False |
No | DEBUG log |
RecordTooBig |
put -> raise AerospikeRecordTooBigError with payload size and configured segment caps |
No (configuration error) | ERROR log + metric aerospike_op_total{op,result="record_too_big"} |
TimeoutError |
get/exists/batched_* -> log WARNING, return miss-equivalent; put/remove_sync -> raise |
Caller decides (LMCache may retry) | WARNING log + metric aerospike_op_total{op,result="timeout"} |
ConnectionError / ClientError (no nodes) |
All ops raise AerospikeConnectionError; ping returns 1 |
No (infra issue) | ERROR log + metric |
RecordKeyMismatch |
Indicates a key collision or send-key mismatch; raise AerospikeInternalError |
No (bug) | ERROR log |
ServerError with AEROSPIKE_ERR_FAIL_FORBIDDEN (22) on TTL writes |
Raise AerospikeTTLConfigError with actionable message about nsup-period |
No (config issue) | ERROR log |
DeviceOverload / QueueFull / KEY_BUSY |
put -> raise AerospikeBusyError; LMCache retry policy decides |
Backoff, jittered retry (delegated to caller) | WARNING log + metric |
Any other AerospikeError |
Raise AerospikeUnknownError wrapping the original |
No | ERROR log with aerospike_error_code |
Startup probe: info call fails |
Raise AerospikeNamespaceProbeError with the raw server response |
No (config / connectivity) | ERROR log |
| Startup probe: parsed cap missing / zero / out of range | Raise AerospikeServerLimitError |
No (server config) | ERROR log |
LMCache itself logs and surfaces these via its instrumented connector wrapper (InstrumentedRemoteConnector); the connector does not duplicate that work but does emit per-op metrics.
- Logging.
lmcache_aerospikeuseslmcache.logging.init_logger(__name__)to share LMCache's logger configuration. Levels:- INFO: connect, close, plugin instance start, scan start/stop.
- DEBUG: per-op key (digest only, never raw bytes), shard plan decisions.
- WARNING: partial reads, timeouts, orphan segments, TTL-on-NSUP-off detection.
- ERROR: all uncategorized exceptions and the
RecordTooBig/TTLConfigErroractionable cases.
- Metrics (opt-in).
metrics.pyregisters Prometheus collectors only ifprometheus_clientis importable. Metrics:aerospike_op_total{op,result}(counter) - one ofget,put,exists,batched_*,remove;resultishit,miss,ok,timeout,record_too_big,busy,error.aerospike_op_latency_seconds{op}(histogram) - buckets tuned for 1 ms - 10 s.aerospike_segment_count(histogram) - per-putshard count; helps tunetarget_segment_bytes.aerospike_segment_bytes(histogram) - per-putsegment size in bytes.aerospike_concurrent_in_flight(gauge) - currentbatch_max_in_flightutilization.
- Unit tests (
tests/unit/). No network.aerospike.Clientis replaced with aunittest.mock.MagicMockor a thin fake. Coverage:ChunkShardPlanner: thresholds, boundary attarget_segment_bytes, boundary atmax_segment_bytes, oversize raise, server-clamped target behavior.get-configresponse parser: sample responses from Aerospike 7.1+ (max-record-size=4194304,flush-size=131072), 7.0 (write-block-size=1048576), and 6.x (write-block-size=131072). Verifyeffective_max_segment_bytescalculation including the 64 KiB safety margin.- Startup probe failure modes: missing namespace, unreachable cluster, out-of-range parsed value, all raise the correct typed error and do not silently default.
- Operator override clamping: configured
max_segment_bytesabove the discovered cap is clamped with WARNING; below is accepted as-is. - Atomicity: a simulated mid-write failure leaves no
state="ready"meta; a reader sees miss. batched_containsprefix semantics: matches the Redis connector's behavior for[True, True, False, True]->2.- Error mapping: every entry in the Section 4.7 table.
- Partial-chunk reshape via
reshape_partial_chunkforbytes_read < full_chunk_size_bytes.
- Integration tests (
tests/integration/). Adocker-compose.ymlspins a single-node Aerospike CE container based on~/github/agent-skills/skills/aerospike-getting-started/SKILL.md(ports 3000-3002 exposed; namespacelmcachewithnsup-period 120). Tests:- Startup probe: assert the connector logs the discovered
max-record-size/write-block-size,effective_max_segment_bytes, and final clampedtarget_segment_bytes. - Live cap change: stop the container, edit
aerospike.confto lowermax-record-size, restart, reconnect, assert puts above the new cap now shard differently. - Round-trip payloads at 256 B, 64 KiB, 1 MiB, 4 MiB, 16 MiB, 64 MiB; assert correct shard count from
meta["nseg"]given the discovered cap. - TTL expiry: write with
default_ttl_seconds=2, sleep 5, assert miss. - Pinned keys: write with TTL
-1, sleep pastdefault_ttl_seconds, assert hit. - Crash mid-write simulation: write segments, kill before meta; reader sees miss; TTL eventually cleans segments.
- Multi-instance plugin: write to
aerospike.primary, read miss fromaerospike.dr.
- Startup probe: assert the connector logs the discovered
- Bench harness (
tests/bench/).pytest-benchmark-driven synthetic chunk stream emulating Llama 3.1 70B at TP=8 (chunk sizes derived from the ai-strategy LMCache evaluation). Measure:getp50/p95/p99 latency for 100% hit and 100% miss workloads.putp50/p95/p99 latency.- Sustained bytes/s under 64-concurrent
batched_get. - CPU% of the connector thread pool under load.
- LMCache integration smoke. A pytest fixture launches vLLM with LMCache configured against the connector and a tiny model (e.g. Llama 3.2 1B). The test sends two identical long prompts back-to-back across a worker restart and asserts the second request's TTFT is materially lower, indicating cache reuse via Aerospike.
- CI matrix. Python 3.10/3.11/3.12/3.13 on Linux x86_64. Integration tests are gated behind
RUN_INTEGRATION=1so unit tests run on every PR while integration runs on merge tomain.
-
Versioning.
0.1.x- Phase 1 alpha. API may change. Integration smoke required to cut a release.0.2.0- Phase 1 stable. Public API frozen for0.2.x.0.3.x- Phase 2 alpha (StoragePluginInterface).0.4.x- Phase 2 stable.1.0.0- tracked against the LMCache1.xmajor release line, with native connector as an opt-in extra.
-
PyPI. Package name
lmcache-aerospike. Wheels and sdist. No compiled code in Phase 1, so the wheel ispy3-none-any. -
Install instructions (to land in
README.md):pip install lmcache lmcache-aerospikefollowed by the YAML snippet from Section 4.5.1.
-
Upstream LMCache. Coordinate with LMCache maintainers to list
lmcache-aerospikeon the storage backends index page. This is an open question in Section 7 because the upstream listing policy is not documented. -
Compatibility matrix. A small table in
README.md:lmcache-aerospikelmcachePython Aerospike server 0.1.x>=0.4.5,<0.53.10-3.13 CE 7.x or 8.x Bump the matrix per release; never overpromise compatibility.
Phase 2 promotes Aerospike from "remote byte store called by LMCache" to "first-class LMCache storage participant." Two surfaces are in scope: StoragePluginInterface (single-process) and L2AdapterInterface Python plugin (multiprocess). Both are kept under the same lmcache-aerospike package, as opt-in entry points.
- Eliminate one buffer hop. Phase 1 receives bytes through
LocalCPUBackend.allocate. Phase 2 lets the plugin own admission and allocation, removing the intermediate buffer for direct-to-Aerospike writes and direct-to-cuda staging on reads. - Pin/unpin fidelity. Phase 1 ignores the
pinargument inbatched_async_contains. Phase 2 can implement pin as a TTL refresh on hit (Client.touchwith a longer TTL) and unpin as TTL restoration. - Admission control. Phase 2 can refuse writes when Aerospike is at device-overload, or apply per-
ttl_classquotas, in a way Phase 1 cannot. - Customer ask. A design partner running LMCache in multiprocess mode will want native
L2AdapterInterfacerather than going through the simpler remote connector.
The exact upstream interfaces are documented in LMCache storage plugins. The Phase 2 work items are:
- Implement
AerospikeStoragePlugin(StoragePluginInterface)mapping to the Phase 1AerospikeRemoteConnectorinternals where possible (the data model is unchanged). - Implement
AerospikeL2Plugin(L2AdapterInterface)for multiprocess mode; share theAerospikeClientHolderacross processes via a small IPC handshake (process A creates the client; process B receives the connection config and creates its own client - we do not share the C-level client across processes). - Lifecycle: register both classes in
pyproject.toml[project.entry-points."lmcache.storage_plugins"]so users can switch surfaces with a config change rather than installing a different package.
The meta + segment data model from Section 4.3 is the same. Phase 2 changes only how records are accessed (admission, pin, allocation), not what is stored. This means Phase 2 can read Phase 1's records and vice versa.
- Multiprocess setup. The L2 plugin runs in each LMCache worker process. Each process gets its own
aerospike.Client; the holder's ref-count semantics still apply within a process. eventfdcompletion bridge. L2native_pluginpaths expecteventfd-style completion signaling. The Python L2 plugin in this Phase ships withouteventfd(Python'sselectorsmodule is sufficient for the completion-callback pattern); thenative_pluginvariant is deferred to Phase 3.- Lifecycle ownership. L2 plugins participate in LMCache's worker startup/shutdown handshake. The plugin must register cleanup callbacks so the executor and client close on worker shutdown, not only on Python interpreter exit.
- Memory ownership. The L2 plugin allocates directly (no
LocalCPUBackendintermediary), so it must implement an internal allocator (a simple slab overbytearrayis enough for Phase 2; pinned-memory allocation is a Phase 3 concern).
- Interface stability.
StoragePluginInterfaceandL2AdapterInterfaceare younger thanRemoteConnector; expect breaking changes upstream during the Phase 2 window. - Multiprocess deployment complexity. Debugging crashes across worker processes is harder. Tests must cover both single-process and multiprocess code paths.
- Performance regression risk. Removing the
LocalCPUBackendhop sounds like a win but only if the Aerospike client's threading model is fast enough on its own; benchmarks before promoting Phase 2 to stable.
Promote when any of the following is observed in a real deployment:
- Phase 1
LocalCPUBackendbuffer copy is measured at >=20% of totalgetorputlatency in the bench harness. - A customer requires pin/unpin fidelity for compliance or eviction-control reasons.
- A customer runs LMCache in multiprocess mode and surfaces the
RemoteConnectorposture as a limitation.
Until any of these triggers, Phase 1 is the recommended path and Phase 2 stays at design-only.
Phase 3 replaces the Python L2 hot path with a C++ implementation modeled after LMCache's native RESP connector (resp_client.py, NativeConnectorL2Adapter, and the ConnectorBase protocol it pairs with). Redis' winning techniques are the native mechanics: C++ worker tiling, GIL-free pybind submissions, one eventfd-backed completion stream, and direct copies into LMCache-provided buffers. Phase 3 adopts those techniques first while preserving the Phase 1/2 Aerospike schema.
The Python connector ceiling is set by GIL contention on the executor pool, copy overhead through memoryview, and the synchronous Aerospike Python client's per-call C extension setup. For sustained multi-GB/s per worker (Llama 70B class at large TP, or multiple concurrent inference requests), this overhead becomes the bottleneck. A native connector closes that gap.
- Language and bindings. C++17 implementation; pybind11 binding exposed as
lmcache_aerospike._nativeand loaded through LMCache'snative_pluginL2 adapter. - LMCache native contract. Expose
event_fd,submit_batch_get,submit_batch_set,submit_batch_exists,submit_batch_delete,drain_completions, andclose, matchingLMCACHE_BIND_CONNECTOR_METHODSsemantics soNativeConnectorL2Adapterhandles demux, locking, and L2 task accounting. - Client. Official Aerospike C client (
libaerospike) with one shared cluster client per native connector instance; workers issue key operations against that client with read/write policies matching Phase 1/2 defaults. - Threading. Use the same worker tiling model as LMCache Redis' native connector: each submitted batch is split across C++ worker threads, and one completion is emitted when all tiles finish. The Python side never holds the GIL after pybind has extracted key strings and memoryview pointers.
- Buffers. Writes wrap LMCache-supplied buffers with Aerospike C client bytes values where the API allows; reads copy Aerospike bytes directly into LMCache's preallocated
MemoryObjbuffers without a Pythonbyteshop. - Completion model. Eventfd-based, with per-key result bits for lookup/load/delete and one completion per submitted batch.
- Data model. Default native layout is the Phase 1/2 meta+segment schema: inline payload bin
bfor single-record objects, segment records for larger objects, and meta-last publish semantics withstate,nseg,seg_b, andtot_b. Because L2 loads are preallocated, native code does not need to add new LMCache shape/dtype metadata; it only preserves the existing bins required for compatibility and sharding correctness.
Phase 3 does not begin by switching to a Redis-like raw one-record schema. That schema can reduce bins and branching, but it would break compatibility with Phase 1/2 records unless every Aerospike path migrates together.
The allowed future paths are:
- Separate raw native mode: keep Phase 1/2 compatible schema as the default, and add an opt-in raw native schema if benchmarks prove schema overhead is a top bottleneck.
- Coordinated schema migration: change Phase 1, Phase 2, and Phase 3 to the faster schema together, with explicit migration or dual-read support.
Do not make a schema-breaking change on guesswork. The benchmark loop must first show that the compatible schema, rather than Python overhead, Aerospike policy choices, worker count, network/device bandwidth, or batch shape, is one of the top bottlenecks.
- Build system. CMake driving pybind11;
cibuildwheelfor manylinux wheels. - manylinux wheels.
manylinux_2_28_x86_64andmanylinux_2_28_aarch64. - Source build fallback. If wheels are unavailable,
pyproject.tomlships a source distribution that links against systemlibaerospike-dev. - Runtime linkage. Dynamic linkage against
libaerospike.soshipped in the wheel; major-version bumps oflibaerospikerequire a new wheel. - CI. Build matrix expands to include the wheel build per platform; native-connector tests gated behind
RUN_NATIVE=1.
- Build matrix cost. manylinux wheels, ABI compatibility across
libaerospikereleases, debug story (gdb on the native side, py-spy on the Python side, correlating them). - Upstream tracking. LMCache's
ConnectorBaseis the youngest surface; tracking changes will be ongoing work. - Operational surface. Customers debugging will need both Python and C++ familiarity.
- Schema pressure. Preserving Phase 1/2 schema may leave some performance on the table versus Redis' raw key/value storage. Treat this as a measured optimization decision, not a Phase 3 prerequisite.
Promote when any of the following is observed:
- Bench harness reports sustained CPU saturation on the connector thread pool while the Aerospike cluster has headroom.
- A customer requires sustained per-worker throughput above what Phase 1/2 measured (the exact GB/s threshold is workload-specific; document the customer's actual number as part of the promotion decision).
- LMCache upstream stabilizes
native_pluginto a level where the maintenance cost is acceptable.
These are deliberately not answered in this design. Each is a follow-up that should be resolved before or during the relevant Phase.
- Chunk-size distribution. What is the actual byte-size distribution of LMCache chunks for the first design-partner model, TP, dtype, and
chunk_size? The 4 MiB target is a defensible default; the bench harness should validate it against real data. - Cache tier intent. Is the deployment intent hot/warm (minutes-hours TTL), durable shared (days-weeks TTL), or pinned-corpus (
-1TTL)? Defaults are documented but customer expectations may differ. - Cross-region cache sharing. XDR is EE-only. CE customers needing cross-region sharing get dual-write via two plugin instances; is that an acceptable productization, or do they need a different solution?
- Controller participation. LMCache's optional controller tracks worker/chunk locations. Phase 1 does not contribute. Should Phase 2 publish location metadata to the controller for cache-aware routing?
- Upstream listing. Will LMCache list
lmcache-aerospikein the storage backends index, and what is their process for accepting an out-of-tree connector? No commitment yet. - CRC32 default. Phase 1 ships
enable_crc32=false. LMCache and the Aerospike storage layer both provide their own integrity guarantees; do we need application-layer CRC, or is it overhead? - Layerwise / MLA mode. Phase 1 should handle
LayerCacheEngineKey(which encodeslayer_idin the key string) correctly without a special code path. Verify with a layerwise integration test before Phase 1 stable. - Authentication. CE has no auth. EE auth fields (
username,password,tls_name) are plumbed but inert in Phase 1. Phase 2+ should validate against an EE cluster. - List API. Is the disabled-by-default
list()sufficient for ops, or do we need a paginated public API for catalog inspection? - Repair scan. When (if ever) should
enable_repair_scanship? The passive TTL-based cleanup may be sufficient indefinitely.
lmcache/v1/storage_backend/connector/__init__.py-ConnectorAdapter,ConnectorContext,ConnectorManager,CreateConnector,DynamicConnectorAdapter,extract_plugin_type.lmcache/v1/storage_backend/connector/base_connector.py-RemoteConnectorabstract base class. Source of every method signature in Section 4.4.lmcache/v1/storage_backend/connector/redis_connector.py- Reference implementation; the Aerospike connector mirrors its priority bands, batched contains semantics, and metadata-as-companion-record pattern (although Aerospike collapses to a single record when payload fits).lmcache/v1/storage_backend/connector/fs_connector.py- Canonicalsave_chunk_metapattern: store one serializedRemoteMetadatablob and allocate from it on read, else allocate from the connector's fixed metadata andreshape_partial_chunk. The Aerospikemdbin and read path mirror this.lmcache/v1/storage_backend/remote_backend.py- The layer above the connector. Confirms (a) serde (naive/cachegen/kivi) and MLA/layerwise key rewriting happen here, not in the connector, and (b)init_connectioncallsCreateConnectorbut neverpost_init()— the basis for construction-time discovery.lmcache/v1/storage_backend/storage_manager.py- Confirmspost_init()is not invoked at this layer either.lmcache/v1/protocol.py-RemoteMetadata.serialize/deserializeused for the meta record's singlemdblob bin (supersedes the earlier per-fieldshape*/dtype/fmtbins).lmcache/utils.py-CacheEngineKeyandLayerCacheEngineKeydefinitions.lmcache/v1/storage_backend/native_clients/resp_client.py- Phase 3 reference pattern.
- LMCache paper: "LMCache: An Efficient KV Cache Layer for Enterprise-Scale LLM Inference", arXiv:2510.09665. Source of the MB-scale chunk-transfer sweet spot that motivates the 4 MiB
target_segment_bytesdefault (see Section 4.3.4). - Remote storage plugins (Phase 1).
- Storage plugins / L2 adapters (Phase 2).
- Native connectors (Phase 3).
- Architecture overview.
- Engine integration.
- Redis backend.
- Storage backends index.
- LMCache Controller.
8.3 Aerospike modeling rules (agent-skills)
client-singleton.mdclient-pools-warmup.mdclient-direct-node-access.mdpolicy-client-defaults.mdpolicy-reuse-timeouts-retries.mdpolicy-send-key.mdpolicy-write-commit-level.mdpolicy-generation-cas.mdpolicy-replace-whole-record.mdpolicy-read-replica-consistency.mdsingle-record-operations.mdsingle-ttl-expiration-retention.mdsingle-ttl-nsup-default-ttl.mdsingle-delete-durable-deletes.mdmodel-access-paths-denormalization.mdmodel-record-size-hardware-efficiency.mdmodel-hot-keys.mdcdt-bounded-collections.mdbatch-parallel-key-operations.mdbinop-operate-atomicity.mdquery-secondary-index-discipline.mdsec-client-tls-auth.md
- Aerospike Documentation home.
- Data model and record sizing.
- Namespace retention / NSUP.
- Python client.
- Aerospike getting started skill - Docker single-node CE setup for integration tests.
- LMCache evaluation in the ai-strategy repository:
~/.config/superpowers/worktrees/ai-strategy/add-lmcache-evaluation/ai/frameworks/lmcache/README.md. Source for the customer-relevance framing, the storage-tier positioning vs Redis/S3/Mooncake, the scoring matrix, and the original sketch of the Phase 1 connector data model that this doc refines.