Companion to: DESIGN.md.
Audience: An implementer (human or AI) who will build the Phase 1 Python
RemoteConnector plugin step by step. This document is intentionally
prescriptive: do exactly what each step says, in order, and run the
verification gate at the end of each step before moving on.
Golden rule: If a step's verification gate fails, STOP and fix it before continuing. Do not skip gates. Do not "batch" steps.
- Steps are numbered
S0,S1, … and must be done in order. - Every step has three parts:
- Goal — one sentence on what this step produces.
- Do — the exact actions / file contents.
- Verify (gate) — a command or check that must pass before the next step.
- Code blocks that are new files to create are shown as normal fenced blocks
with a
# file: <path>header comment on the first line. Create the file at that path with that content (minus the header comment if it is not valid in that language — for Python keep it, fortoml/yamlkeep it). - Where this plan deviates from
DESIGN.md, the deviation is marked ⚠ DESIGN-CORRECTION with the reason. The plan is the source of truth for implementation;DESIGN.mdshould later be updated to match (tracked in Step S16).
These were found by checking the design against the actual upstream LMCache
dev source and the real Aerospike Python client API. They are baked
into the steps below; this list is so you understand why.
-
⚠
post_init()is never called for a remote connector. UpstreamRemoteBackend.init_connection()callsCreateConnector()and stores the result; nothing callsconnector.post_init(). Therefore server-side record-size discovery MUST happen during connector construction (lazily, guarded), NOT inpost_init. We still keep apost_initoverride for forward-compat, but we never rely on it being called. -
⚠
batch_writetakes aBatchRecordsobject, not a list of(key, bins, meta)tuples. Useaerospike_helpers.batch.records.{BatchRecords, Write, Read, Remove}andaerospike_helpers.operations.operations as op. -
⚠
exists_many/get_many/select_manyare removed in current Aerospike Python clients. Useclient.batch_read(keys, bins=[...])for reads andclient.batch_read(keys, bins=[])(empty bin list = metadata only) for existence checks. -
⚠
meta={"ttl": N}is deprecated as of client v19.1.0. Pin a specific client version (Step S1) and set TTL through ONE helper so the API is in exactly one place. -
⚠ The connector is serde-agnostic.
remote_serde(naive/cachegen/kivi) is applied byRemoteBackendabove the connector. The connector stores opaque bytes and reconstructs aMemoryObjthat the deserializer can consume. Metadata handling MUST honorself.save_chunk_metaexactly like upstreamFSConnector:- if
save_chunk_metaisTrue: store a serializedRemoteMetadatablob and, on read, allocate from it (do NOT callreshape_partial_chunk); - if
save_chunk_metaisFalse: store no metadata, allocate fromself.meta_shapes/meta_dtypes/meta_fmt, and callreshape_partial_chunk(memory_obj, bytes_read)on read.
- if
-
⚠ Construction shape. The adapter is instantiated by LMCache with no arguments (
AerospikeConnectorAdapter()), thencreate_connector(context)is called. Readconfigandmetadatafromcontext.local_cpu_backend(.config,.metadata) — the canonical upstream pattern — falling back tocontext.config/context.metadata. The URL LMCache passes for a plugin isplugin://{plugin_name}.
| Thing | Pin | Why |
|---|---|---|
| Python | >=3.10,<3.14 |
Tracks LMCache supported range |
aerospike (client) |
>=14.0.0,<19.0.0 |
Modern batch API present; pre-19 so meta={"ttl"} still valid. If you must use >=19, change ONLY the _ttl_meta/_write_policy helper (Step S8) per that version's docs |
lmcache |
pin a single known-good release, e.g. >=0.4.5,<0.5 |
Peer dependency; the connector contract is from the dev/0.4.x line |
| Aerospike server (tests) | CE 7.x or 8.x Docker image aerospike/aerospike-server |
Matches design |
If any pin is unavailable at implementation time, STOP and ask. Do not invent a version.
RemoteConnector— abstract base,lmcache.v1.storage_backend.connector.base_connector. Abstract methods:exists,exists_sync,get,put,list,close. Non-abstract (override to enable):ping/support_ping,batched_get/support_batched_get,batched_put/support_batched_put,batched_contains/support_batched_contains,remove_sync. Defaults alreadyTruewith base impls:support_batched_async_contains,support_batched_get_non_blocking.ConnectorAdapter— abstract base,…connector.__init__.__init__(self, schema=""),can_parse(self, url)->bool, abstractcreate_connector(self, context)->RemoteConnector.ConnectorContext— has.url,.loop,.local_cpu_backend,.config,.metadata,.plugin_name.local_cpu_backendmay be aSafeLocalCPUBackendstub (scheduler role) — but forRemoteBackendit is always real.extract_plugin_type(plugin_name)—"aerospike.primary" -> "aerospike".RemoteMetadata—lmcache.v1.protocol. Constructor:RemoteMetadata(length:int, shapes:list[torch.Size], dtypes:list[torch.dtype], fmt:MemoryFormat). Methods:.serialize()->bytes,.deserialize(buf)->RemoteMetadata(static),.serialize_into(buffer). Header size is fixed per process:get_remote_metadata_bytes()(the base class stores it onself.remote_metadata_bytes).MemoryObj—lmcache.v1.memory_management. You will use:.byte_array(amemoryview/buffer),.get_size(),.get_shapes(),.get_dtypes(),.get_memory_format(),.ref_count_down(),.raw_data,.meta.shape,.meta.dtype. Allocation:local_cpu_backend.allocate(shapes, dtypes, fmt)returnsMemoryObjorNone.CacheEngineKey/LayerCacheEngineKey—lmcache.utils. Use.to_string()(stable string) and.chunk_hash. Layerwise keys differ only in their string; no special code path is needed beyond usingto_string()everywhere.- Base-class attributes available after
super().__init__(config, metadata):self.save_chunk_meta(bool),self.meta_shapes,self.meta_dtypes,self.meta_fmt,self.full_chunk_size_bytes,self.single_token_size,self.remote_metadata_bytes.
Goal: Before writing the package, confirm the LMCache contract symbols and the Aerospike client API actually exist as this plan assumes, on the pinned versions. This prevents building on wrong assumptions.
Do:
- Create and activate a fresh virtualenv (Python in the pinned range).
- Install the pinned
lmcacheandaerospikeversions plus dev tooling:pip install "aerospike>=14,<19" "lmcache>=0.4.5,<0.5" pytest pytest-asyncio. (CPU-onlytorchis pulled in bylmcache.) - Run this probe script and read its output. Save it as
scripts/preflight.py(temporary; it is not shipped):
# file: scripts/preflight.py
import inspect
import aerospike
from aerospike_helpers.batch import records as br
from aerospike_helpers.operations import operations as op
from lmcache.v1.storage_backend.connector.base_connector import RemoteConnector
from lmcache.v1.storage_backend.connector import (
ConnectorAdapter, ConnectorContext, extract_plugin_type,
)
from lmcache.v1.protocol import (
RemoteMetadata, get_remote_metadata_bytes, init_remote_metadata_info,
)
# 1) RemoteConnector abstract methods present
need = {"exists", "exists_sync", "get", "put", "list", "close"}
have = set(RemoteConnector.__abstractmethods__)
assert need <= have, f"missing abstract methods: {need - have}"
# 2) support_* defaults
assert RemoteConnector.support_batched_async_contains(RemoteConnector) is True
assert RemoteConnector.support_batched_get_non_blocking(RemoteConnector) is True
assert RemoteConnector.support_batched_contains(RemoteConnector) is False
# 3) ConnectorAdapter shape
assert "create_connector" in ConnectorAdapter.__abstractmethods__
assert extract_plugin_type("aerospike.primary") == "aerospike"
# 4) Aerospike client method names this plan uses
for name in ("put", "get", "select", "exists", "remove",
"batch_read", "batch_write", "is_connected",
"get_node_names", "info_random_node", "scan"):
assert hasattr(aerospike.Client, name), f"client missing {name}"
# 5) Legacy methods we deliberately AVOID — warn if absent (expected)
for gone in ("exists_many", "get_many", "select_many"):
print(f"legacy {gone} present:", hasattr(aerospike.Client, gone))
# 6) batch records constructors
assert all(hasattr(br, n) for n in ("BatchRecords", "Write", "Read", "Remove"))
assert hasattr(op, "write") and hasattr(op, "read")
# 7) TTL constants
for c in ("TTL_NEVER_EXPIRE", "TTL_NAMESPACE_DEFAULT", "TTL_DONT_UPDATE"):
print(c, "=", getattr(aerospike, c, "MISSING"))
# 8) RemoteMetadata round-trip (num_groups=1)
init_remote_metadata_info(1)
print("remote_metadata_bytes =", get_remote_metadata_bytes())
print("OK: preflight passed")- Inspect the exact
putTTL mechanism for your installed client version:python -c "import aerospike, inspect; help(aerospike.Client.put)"and note whethermeta={"ttl": N}is accepted (it is for<19). Record the answer in a comment you will paste into Step S8's_apply_ttlhelper.
Verify (gate):
python scripts/preflight.pyprintsOK: preflight passedwith no AssertionError.- The TTL constants print integer values (not
MISSING). - If
batch_write/batch_readare missing, youraerospikeversion is too old — STOP and fix the pin.
Goal: A pip-installable, import-clean package skeleton.
Do:
- Create this exact layout (empty files where noted):
lmcache-aerospike/
pyproject.toml
src/lmcache_aerospike/
__init__.py
adapter.py
connector.py
client.py
config.py
keys.py
sharding.py
serde.py
limits.py
policies.py
errors.py
metrics.py
tests/
unit/__init__.py
integration/__init__.py
bench/__init__.py
docker/
docker-compose.yml
aerospike.conf
- Write
pyproject.toml:
# file: pyproject.toml
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "lmcache-aerospike"
version = "0.1.0"
description = "Aerospike remote storage backend for LMCache"
readme = "README.md"
requires-python = ">=3.10,<3.14"
license = { text = "Apache-2.0" }
dependencies = [
"aerospike>=14.0.0,<19.0.0",
"lmcache>=0.4.5,<0.5",
]
[project.optional-dependencies]
metrics = ["prometheus_client>=0.19"]
dev = ["pytest>=7", "pytest-asyncio>=0.23", "pytest-benchmark>=4"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]src/lmcache_aerospike/__init__.py:
# file: src/lmcache_aerospike/__init__.py
"""Aerospike remote storage backend for LMCache."""
from lmcache_aerospike.adapter import AerospikeConnectorAdapter
from lmcache_aerospike.connector import AerospikeRemoteConnector
__all__ = ["AerospikeConnectorAdapter", "AerospikeRemoteConnector"]
__version__ = "0.1.0"NOTE:
__init__.pyimportingadapter/connectormeans those modules must import cleanly even before all helpers exist. While building, it is fine for__init__.pyto temporarily not import them; restore the imports at S9/S10.
Verify (gate):
pip install -e ".[dev]"succeeds.python -c "import lmcache_aerospike"succeeds once S9/S10 are done (for now, with empty modules, just confirmpip install -e .works and the directory imports: temporarily comment the imports in__init__.py).
Goal: A frozen dataclass AerospikeConfig built from LMCache's
extra_config dict, with strict validation and clear error messages.
Do:
- Keys are read from
extra_configunder the prefixremote_storage_plugin.{plugin_name}.(e.g.remote_storage_plugin.aerospike.hosts).plugin_namemay beaerospikeoraerospike.primary. - Implement
AerospikeConfigwith these fields and defaults. Thetarget_segment_bytesdefault is 4 MiB — this is intentional and matches the LMCache authors' guidance that MB-scale chunks are the sweet spot between per-transfer network/round-trip overhead and transfer time (LMCache paper, "An Efficient KV Cache Layer for Enterprise-Scale LLM Inference", arXiv:2510.09665, §4.1 Batched Operations / §7 transfer-granularity evaluation: page-level KB transfers underutilize bandwidth; MB-scale chunk transfers are required to saturate PCIe/network links). Aerospike's own 1–10 KiB record sweet spot does NOT apply here because LMCache is byte-throughput-dominated, not ops-dominated; we deliberately trade index-RAM efficiency for fewer round trips, and the value is still clamped to the server's discovered record-size cap at startup:
| Field | Type | Default | Notes |
|---|---|---|---|
hosts |
tuple[tuple[str,int],...] |
required | parse "h1:3000,h2:3000" |
namespace |
str | "lmcache" |
|
set_name |
str | "kv_chunks" |
Aerospike set (avoid keyword set) |
target_segment_bytes |
int | 4194304 (4 MiB) |
preferred segment size (LMCache authors' MB-scale sweet spot, arXiv:2510.09665); clamped down to the server cap at startup |
max_segment_bytes |
int|None |
None (discovered) |
operator override; clamped to server cap |
min_segment_bytes |
int | 65536 (64 KiB) |
|
single_record_threshold_bytes |
int|None |
None → min(target,server_cap) |
|
default_ttl_seconds |
int | 86400 |
0/-1/-2 honored |
read_timeout_ms |
int | 1000 |
|
write_timeout_ms |
int | 2000 |
|
batch_max_in_flight |
int | 64 |
|
executor_threads |
int | 16 |
|
enable_list |
bool | False |
|
enable_crc32 |
bool | False |
|
commit_level |
str | "all" |
all|master |
replica |
str | "sequence" |
master|any|sequence|prefer_rack |
send_key |
bool | False |
|
username |
str | "" |
EE only; inert |
password |
str | "" |
EE only; inert |
tls_name |
str | "" |
EE only; inert |
- Provide a classmethod:
AerospikeConfig.from_extra_config(extra_config: dict | None, plugin_name: str) -> AerospikeConfig.- If
extra_configisNone, treat as empty. - Missing required key (
hosts) → raiseAerospikeConfigError("…hosts is required"). - Type/range errors → raise
AerospikeConfigErrornaming the offending key. commit_level/replicanot in the allowed set →AerospikeConfigError.- Booleans parsed from
True/False/"true"/"false"/"1"/"0"case-insensitively.
- If
- Add a small helper
key(self, name)that returns the fully-qualified config key string for error messages, e.g.remote_storage_plugin.aerospike.hosts.
Verify (gate): Unit test (write now, run in S11):
- valid dict → all fields parsed, hosts tuple correct;
- missing
hosts→AerospikeConfigErrormentioninghosts; commit_level="bogus"→AerospikeConfigErrormentioningcommit_level;- instance-scoped keys (
aerospike.primary) resolved correctly.
Goal: One module that defines the connector's exception types and a single
map_aerospike_error(exc) function used everywhere.
Do:
- Define exceptions (all subclass
AerospikeConnectorError(Exception)):AerospikeConfigError,AerospikeConnectionError,AerospikeRecordTooBigError,AerospikeTTLConfigError,AerospikeBusyError,AerospikeNamespaceProbeError,AerospikeServerLimitError,AerospikeInternalError,AerospikeUnknownError. - Import the Aerospike exception module as
from aerospike import exception as ax. - Implement
classify(exc) -> strreturning one of:"not_found","too_big","timeout","connection","forbidden_ttl","busy","key_mismatch","unknown". Mapping (verified against skills + Aerospike error codes):
| Aerospike exception | classify() | Connector behavior (caller decides) |
|---|---|---|
ax.RecordNotFound |
not_found |
get→None; exists→False; remove_sync→False |
ax.RecordTooBig |
too_big |
put→raise AerospikeRecordTooBigError |
ax.TimeoutError |
timeout |
reads→miss; writes→raise |
ax.ConnectionError, ax.ClientError (no nodes) |
connection |
raise AerospikeConnectionError; ping→1 |
ax.AerospikeError with e.code == 22 (FORBIDDEN) on a TTL write |
forbidden_ttl |
raise AerospikeTTLConfigError (point at nsup-period) |
ax.DeviceOverload, ax.QueueFull, code 14 (KEY_BUSY) |
busy |
put→raise AerospikeBusyError |
ax.RecordKeyMismatch |
key_mismatch |
raise AerospikeInternalError |
any other ax.AerospikeError |
unknown |
raise AerospikeUnknownError |
Aerospike exceptions expose .code and .msg; use getattr(exc, "code", None).
Error code 22 = AEROSPIKE_ERR_FAIL_FORBIDDEN; 14 = KEY_BUSY. Be
defensive: match on both the exception subclass AND .code where the table
uses a code.
4. map_aerospike_error(op_name, exc) -> AerospikeConnectorError returns the
right typed exception with a message containing op_name, the original
.code, and .msg.
Verify (gate): Unit test constructs fake exceptions (subclasses with .code)
and asserts classify() returns the expected bucket for every row above.
Goal: Deterministic Aerospike primary keys for the meta record and segment
records. Works for both CacheEngineKey and LayerCacheEngineKey with no
special path (both implement .to_string()).
Do:
- The Aerospike "user key" is a string. Build it from
ck.to_string():- meta record user key:
f"{ck.to_string()}|m" - segment record user key:
f"{ck.to_string()}|s|{i}"foriin[0, nseg).
- meta record user key:
- An Aerospike key tuple is
(namespace, set_name, user_key). Implement:
# file sketch: src/lmcache_aerospike/keys.py
def meta_key(ns: str, set_: str, ck) -> tuple:
return (ns, set_, f"{ck.to_string()}|m")
def segment_key(ns: str, set_: str, ck, i: int) -> tuple:
return (ns, set_, f"{ck.to_string()}|s|{i}")
def segment_keys(ns: str, set_: str, ck, nseg: int) -> list[tuple]:
return [segment_key(ns, set_, ck, i) for i in range(nseg)]- Do NOT enable
send_keyby default; the digest is computed from the user key string, so the logical key is fully recoverable in our own code without storing it server-side.
Verify (gate): Unit test: meta_key and segment_key(...,3) produce the
exact expected tuples for a synthetic key string; segment_keys(...,4) returns
4 tuples with suffixes |s|0..|s|3.
Goal: A pure, no-I/O planner that decides single-record vs N-segment layout.
Do:
- Define a result dataclass
ShardPlan(nseg: int, seg_b: int). - Implement
plan(payload_bytes, *, target_segment_bytes, max_segment_bytes, min_segment_bytes, single_record_threshold_bytes) -> ShardPlanwith this exact decision rule:- If
payload_bytes <= single_record_threshold_bytesANDpayload_bytes <= max_segment_bytes: returnShardPlan(1, payload_bytes). - Else
nseg = ceil(payload_bytes / target_segment_bytes),seg_b = ceil(payload_bytes / nseg). Ifseg_b > max_segment_bytes, raiseAerospikeConfigError(operator override made target inconsistent). ReturnShardPlan(nseg, seg_b). - (
min_segment_bytesis a warn-only floor: if a non-single plan would produceseg_b < min_segment_bytes, fall back toShardPlan(1, payload_bytes)providedpayload_bytes <= max_segment_bytes.)
payload_bytes == 0→ShardPlan(1, 0)(empty payload is a valid single record).- Use integer math:
nseg = -(-payload_bytes // target_segment_bytes).
- If
- The last segment length is
payload_bytes - (nseg-1)*seg_b; the planner does not need to return it, the writer computes slices (Step S9).
Verify (gate): Unit tests with the production defaults max=8 MiB,
target=4 MiB, min=64 KiB, single_threshold=4 MiB (these exercise every
branch and match the shipped default):
256→(1, 256);4 MiB→(1, 4194304)(single-record fast path);4 MiB + 1→(2, …)withseg_b <= 4 MiBand balanced (seg_b≈2 MiB);16 MiB→(4, 4194304);64 MiB→(16, 4194304);- override making
target>max→AerospikeConfigError; - assert
sum of slice lengths == payload_bytesfor each case.
Goal: Centralize the save_chunk_meta handling and the meta-bin packing so
the connector body stays simple. This is the step most likely to be done
wrong — follow it exactly. It mirrors upstream FSConnector.
Context (why): RemoteBackend serializes the MemoryObj (naive/cachegen/
kivi) before calling connector.put, and deserializes after
connector.get. So the connector stores opaque bytes. To rebuild a MemoryObj
of the right shape on read, we either (a) store the per-object RemoteMetadata
(when save_chunk_meta is True), or (b) rely on the fixed full-chunk shape and
reshape_partial_chunk (when False).
Do:
- Implement
build_remote_metadata(memory_obj) -> RemoteMetadata:
# file sketch: src/lmcache_aerospike/serde.py
from lmcache.v1.protocol import RemoteMetadata
def build_remote_metadata(memory_obj) -> RemoteMetadata:
buf = memory_obj.byte_array
return RemoteMetadata(
len(buf),
memory_obj.get_shapes(),
memory_obj.get_dtypes(),
memory_obj.get_memory_format(),
)- Implement
meta_bins(*, plan, memory_obj, save_chunk_meta, enable_crc32, default_ttl, pinned) -> dictreturning the bins for the META record:- Always include:
ver=1(int),state="ready"(str),nseg(int),seg_b(int),tot_b=len(byte_array)(int),created_at(int epoch s). - If
save_chunk_metais True: includemd = build_remote_metadata(memory_obj).serialize()(abytesblob). ⚠ DESIGN-CORRECTION: store ONEmdblob bin instead of separateshape0..shape3/dtype/fmtbins — the blob supportsnum_groups > 1and is guaranteed bit-compatible with the deserializer. - If
save_chunk_metais False: do NOT includemd. - If
plan.nseg == 1: includeb = bytes(memory_obj.byte_array)(the inline payload). Fornseg > 1, do NOT includeb(segments hold the payload). - If
enable_crc32: includecrc32 = zlib.crc32(payload) & 0xFFFFFFFF. pin/ttl_classbins optional; includepin=pinned(bool).
- Always include:
- Implement
allocate_for_read(local_cpu_backend, base_self, meta_bins) -> (memory_obj | None, expect_meta_reshape: bool):- If
save_chunk_metais True andmdbin present:rm = RemoteMetadata.deserialize(meta_bins["md"]);mo = local_cpu_backend.allocate(rm.shapes, rm.dtypes, rm.fmt); return(mo, False). - Else:
mo = local_cpu_backend.allocate(base_self.meta_shapes, base_self.meta_dtypes, base_self.meta_fmt); return(mo, True)(caller will callreshape_partial_chunkafter fillingbytes_read). - If
mo is Nonereturn(None, False).
- If
- Implement
write_payload_into(memory_obj, payload: bytes | memoryview) -> int: copies bytes intomemory_obj.byte_arrayand returns bytes written. Guard againstlen(payload) > len(byte_array)(log + raiseAerospikeInternalError).
Verify (gate): Unit test (mock local_cpu_backend.allocate to return a fake
MemoryObj wrapping a bytearray):
- with
save_chunk_meta=True,meta_binscontainsmd, andallocate_for_readreturnsexpect_reshape=Falseand allocates from the deserialized shapes; - with
save_chunk_meta=False, nomd,allocate_for_readreturnsexpect_reshape=True; nseg==1path includesb;nseg>1excludesb.
Goal: Build Aerospike policy dicts once, from AerospikeConfig, mapping the
string config to aerospike.POLICY_* constants.
Do:
- Map config strings to constants:
commit_level:"all" -> aerospike.POLICY_COMMIT_LEVEL_ALL,"master" -> aerospike.POLICY_COMMIT_LEVEL_MASTER.replica:"master"->POLICY_REPLICA_MASTER,"any"->POLICY_REPLICA_ANY,"sequence"->POLICY_REPLICA_SEQUENCE,"prefer_rack"->POLICY_REPLICA_PREFER_RACK.- key policy:
POLICY_KEY_SENDifsend_keyelsePOLICY_KEY_DIGEST.
- Factories return plain dicts (the Python client takes policy dicts):
# file sketch: src/lmcache_aerospike/policies.py
def read_policy(cfg) -> dict:
return {
"total_timeout": cfg.read_timeout_ms,
"socket_timeout": cfg.read_timeout_ms,
"replica": _replica(cfg.replica),
"key": _key_policy(cfg.send_key),
"max_retries": 2, # reads are idempotent
}
def write_policy(cfg) -> dict:
return {
"total_timeout": cfg.write_timeout_ms,
"socket_timeout": cfg.write_timeout_ms,
"commit_level": _commit(cfg.commit_level),
"key": _key_policy(cfg.send_key),
"exists": aerospike.POLICY_EXISTS_IGNORE, # overwrite-always
"gen": aerospike.POLICY_GEN_IGNORE, # no CAS
"max_retries": 0, # writes default non-idempotent
}
def batch_policy(cfg) -> dict:
return {"total_timeout": max(cfg.read_timeout_ms, cfg.write_timeout_ms)}Use getattr(aerospike, NAME) defensively; if a constant is missing on the
installed version, raise AerospikeConfigError naming it (caught in S0 ideally).
3. TTL is NOT set here — it is applied per-write in S8's _apply_ttl helper.
Verify (gate): Unit test asserts the dicts contain the mapped integer
constants for each config string and that an unknown replica value raises.
Goal: One Aerospike client per (hosts, namespace, tls_name) per process,
ref-counted; and server-side record-size discovery that runs at connector
construction (NOT in post_init).
Do:
- Module-level registry:
_HOLDERS: dict[tuple, AerospikeClientHolder] = {}guarded by athreading.Lock. AerospikeClientHolderwraps exactly one connectedaerospike.client(config). The config dict passed toaerospike.client(...)is:
{
"hosts": list(cfg.hosts), # [("h",3000), ...]
"policies": { # client-level defaults
"read": read_policy(cfg),
"write": write_policy(cfg),
"batch": batch_policy(cfg),
},
# EE-only fields (username/password/tls) only if provided; inert on CE
}Construct with aerospike.client(config) then .connect(). ⚠ The Python
client connects on .connect(); do this once.
3. Provide get_or_create(cfg) -> AerospikeClientHolder keyed by
(cfg.hosts, cfg.namespace, cfg.tls_name). Increment refcount on each
acquire. Provide release() that decrements; when it reaches 0, call
client.close() and remove from _HOLDERS.
4. Expose .client (the live aerospike.Client). Holder is thread-safe.
5. Idempotency: release() more times than acquired must not raise (log a
warning and no-op).
⚠ DESIGN-CORRECTION on reconnect:
RemoteBackendmay rebuild the connector (init_connectionafter failure, orrecreate_backend). Because the holder is keyed and ref-counted, a rebuild that constructs a new connector with the same(hosts, namespace, tls_name)reuses the live client (refcount goes 1→2→1). Ensureclose()only tears down at refcount 0.
Do:
SAFETY_MARGIN_BYTES = 65536.discover_limits(client, namespace) -> ServerLimitswhereServerLimits(server_max_record_bytes:int, source:str):- Issue
resp = client.info_random_node(f"namespace/{namespace}"). (⚠ DESIGN-CORRECTION: usenamespace/{ns}— it returns the full namespace config+stats askey=value;key=value. This is what the official clientttl.pyexample uses and is robust across versions. Theget-config:context=namespace;id=form also works but is less consistently supported.) - The response may be prefixed; strip up to the first tab/
\tif present, then split on;and=into a dict. - Choose the cap, most-specific first:
- if
max-record-sizepresent and> 0→ that (Aerospike 7.1+). - elif
write-block-sizepresent and> 0→ that (≤7.0). - else → raise
AerospikeServerLimitError(do NOT guess).
- if
- Validate the chosen value is within
[131072, 8388608](128 KiB..8 MiB); otherwise raiseAerospikeServerLimitError. - On any
infoexception or empty response → raiseAerospikeNamespaceProbeErrorcontaining the raw response.
- Issue
resolve_segment_limits(cfg, server_limits) -> ResolvedLimitscomputes:effective_max = server_max_record_bytes - SAFETY_MARGIN_BYTESmax_segment_bytes = min(cfg.max_segment_bytes or effective_max, effective_max)(clamp override down, WARN if clamped).target_segment_bytes = min(cfg.target_segment_bytes, effective_max)(WARN if clamped).single_record_threshold_bytes = min(cfg.single_record_threshold_bytes or target_segment_bytes, effective_max).min_segment_bytes = cfg.min_segment_bytes.- Return all four + emit the INFO log block (server values, derived, effective) exactly as in DESIGN §4.3.6.
- Also verify the TTL precondition at discovery time: if
cfg.default_ttl_seconds > 0, check that the namespace config hasnsup-period> 0; if it is0, raiseAerospikeTTLConfigErrorwith an actionable message (the alternative is to wait for the first write to fail with code 22 — fail fast instead).
⚠ DESIGN-CORRECTION (placement): discovery is invoked from the connector constructor (Step S9), guarded by
self._limits_ready. It is NOT placed only inpost_init, becausepost_initis never called for remote connectors.
Verify (gate): Unit tests for the parser with three canned info
responses:
- 7.1+: contains
max-record-size=1048576;…;nsup-period=120→ cap1048576, sourcemax-record-size,effective = 1048576-65536. - 7.0:
write-block-size=1048576;…(nomax-record-size) → cap fromwrite-block-size. - bad:
max-record-size=0and nowrite-block-size→AerospikeServerLimitError. nsup-period=0withdefault_ttl_seconds>0→AerospikeTTLConfigError.- empty/garbage response →
AerospikeNamespaceProbeError.
This is the largest step; it is split into S9a–S9d. Implement and unit-test each sub-step before the next.
Do:
- Class signature and constructor (⚠ note arg source):
# file sketch: src/lmcache_aerospike/connector.py
import asyncio, time, zlib
from concurrent.futures import ThreadPoolExecutor
from typing import List, Optional
from lmcache.logging import init_logger
from lmcache.utils import CacheEngineKey
from lmcache.v1.memory_management import MemoryObj
from lmcache.v1.storage_backend.connector.base_connector import RemoteConnector
from lmcache_aerospike.config import AerospikeConfig
from lmcache_aerospike.client import AerospikeClientHolder
from lmcache_aerospike import keys as K
from lmcache_aerospike import serde, policies, limits
from lmcache_aerospike.sharding import plan as shard_plan
from lmcache_aerospike.errors import (map_aerospike_error, classify, ...)
logger = init_logger(__name__)
class AerospikeRemoteConnector(RemoteConnector):
def __init__(self, *, config, metadata, local_cpu_backend, loop,
aerospike_config: AerospikeConfig,
client_holder: AerospikeClientHolder):
super().__init__(config, metadata) # sets save_chunk_meta, meta_*, etc.
self.cfg = aerospike_config
self.local_cpu_backend = local_cpu_backend
self.loop = loop
self._holder = client_holder
self._client = client_holder.client
self._executor = ThreadPoolExecutor(
max_workers=aerospike_config.executor_threads,
thread_name_prefix="as-conn",
)
self._batch_sem = asyncio.Semaphore(aerospike_config.batch_max_in_flight)
self._closed = False
self._limits_ready = False
self._resolved = None
self._ensure_limits() # ⚠ discovery happens HERE, not in post_init_ensure_limits(self)(idempotent, guarded):- if
self._limits_ready: return. server = limits.discover_limits(self._client, self.cfg.namespace)self._resolved = limits.resolve_segment_limits(self.cfg, server)- validate TTL/nsup precondition (limits module did it; surface its error).
- set
self._limits_ready = True. - On
AerospikeNamespaceProbeError/AerospikeServerLimitError/AerospikeTTLConfigError: log ERROR and re-raise (construction fails fast;RemoteBackendcatches and will retry per its reconnect policy).
- if
post_init(self)override: callself._ensure_limits()thensuper().post_init()is NOT needed; justself._ensure_limits(). (Harmless if upstream ever calls it; discovery is already done in__init__.)- TTL helper (the ONLY place TTL touches the client):
def _ttl_value(self, pinned: bool) -> int:
if pinned:
return -1 # never expire
return self.cfg.default_ttl_seconds # 0 => namespace default, >0 seconds
def _put_meta(self, ttl: int) -> dict:
# aerospike client <19: meta={"ttl": N} is valid (confirmed in S0).
# If you pinned client >=19, change THIS function per that version's docs.
return {"ttl": ttl}- Executor dispatch helper:
async def _run(self, fn, *args):
return await self.loop.run_in_executor(self._executor, fn, *args)async def close(self)(idempotent):- if
self._closed: return. self._closed = Trueself._executor.shutdown(wait=True)self._holder.release()(tears down client at refcount 0).
- if
⚠ Cancellation note:
RemoteBackend.get_blockingcancels the outer future onblocking_timeout_secs, but the executor thread keeps running to completion. That is acceptable (Aerospike op is short). Do not attempt to kill threads.
Verify (gate): Unit test constructs the connector with a mocked client +
mocked discover_limits/resolve_segment_limits; asserts _limits_ready is
True after construction and that close() is idempotent and releases the holder.
Do:
def _meta_select_state(self, ck) -> Optional[dict](sync): returns the meta bins needed for existence/state, orNoneif absent:
def _exists_sync_impl(self, ck) -> bool:
mk = K.meta_key(self.cfg.namespace, self.cfg.set_name, ck)
try:
(_k, meta, bins) = self._client.select(mk, ["state"])
except ax.RecordNotFound:
return False
except ax.AerospikeError as e:
raise map_aerospike_error("exists", e)
return meta is not None(select(key, ["state"]) is one round trip and returns existence. Treat any
present meta as a hit — the atomicity protocol writes meta last with
state="ready", so a present meta is effectively ready. Reading state is
for future strictness.)
2. def exists_sync(self, key) -> bool: call _exists_sync_impl directly on the
calling thread (LMCache may call from background threads; the client is
thread-safe).
3. async def exists(self, key) -> bool: return await self._run(self._exists_sync_impl, key).
4. def _get_sync_impl(self, ck) -> Optional[MemoryObj]:
mk = K.meta_key(...);(_k, meta, bins) = self._client.get(mk)→RecordNotFound⇒ return None.- if
bins.get("state") != "ready": return None. nseg = bins["nseg"]; build a meta-bins dict forserde.allocate_for_read.(mo, expect_reshape) = serde.allocate_for_read(self.local_cpu_backend, self, bins); ifmo is None: return None (CPU backend full).- Single record (
nseg == 1):payload = bins["b"](abytes);n = serde.write_payload_into(mo, payload). - Multi-segment (
nseg > 1):seg_keys = K.segment_keys(..., nseg);brs = self._client.batch_read(seg_keys, ["b"]); iteratebrs.batch_recordsIN ORDER; for eachr: ifr.result != 0orr.record is None→ log WARNING ("missing/in-flight segment") and return None; else appendr.record[2]["b"]. Concatenate in order intomovia repeatedwrite_payload_intoat the right offset (or build abytearraythen copy once). Track totaln. - if
enable_crc32: verifyzlib.crc32(payload) & 0xFFFFFFFF == bins["crc32"]; mismatch → log ERROR,mo.ref_count_down(), return None. - if
expect_reshape(i.e.save_chunk_metaFalse):mo = self.reshape_partial_chunk(mo, n)(n must be a multiple ofsingle_token_size; it will be for full LMCache chunks). - return
mo. async def get(self, key):return await self._run(self._get_sync_impl, key). On unexpectedAerospikeError, map viaerrors.py: timeouts → return None (miss); connection errors → raise.
Verify (gate): Unit tests with a fake client:
- single-record hit returns a MemoryObj with the right bytes;
- multi-segment hit concatenates segments in order;
- missing one segment → None (no raise);
state != "ready"→ None;- CRC mismatch (when enabled) → None;
save_chunk_meta=Falsepath callsreshape_partial_chunk.
Do:
- Imports at top of module:
from aerospike_helpers.batch import records as brfrom aerospike_helpers.operations import operations as op def _put_sync_impl(self, ck, memory_obj) -> None:view = memory_obj.byte_array(ensurememoryview);total = len(view).r = self._resolved(resolved limits from S8b).p = shard_plan(total, target_segment_bytes=r.target_segment_bytes, max_segment_bytes=r.max_segment_bytes, min_segment_bytes=r.min_segment_bytes, single_record_threshold_bytes=r.single_record_threshold_bytes).ttl = self._ttl_value(pinned=False);wmeta = self._put_meta(ttl).mbins = serde.meta_bins(plan=p, memory_obj=memory_obj, save_chunk_meta=self.save_chunk_meta, enable_crc32=self.cfg.enable_crc32, default_ttl=ttl, pinned=False).- Single record (
p.nseg == 1):mbinsalready includesb. One call:self._client.put(meta_key, mbins, meta=wmeta, policy=policies.write_policy(self.cfg)). - Multi-segment (
p.nseg > 1):- Build segment writes (⚠ DESIGN-CORRECTION — real batch API):
seg_b = p.seg_b
writes = []
for i in range(p.nseg):
start = i * seg_b
chunk = bytes(view[start:start + seg_b]) # last slice is shorter
seg_ops = [op.write("b", chunk)]
if self.cfg.enable_crc32:
seg_ops.append(op.write("crc32", zlib.crc32(chunk) & 0xFFFFFFFF))
writes.append(br.Write(
key=K.segment_key(self.cfg.namespace, self.cfg.set_name, ck, i),
ops=seg_ops,
meta=wmeta, # TTL per segment
policy=policies.write_policy(self.cfg),
))
batch = br.BatchRecords(writes)
self._client.batch_write(batch)
# inspect per-record results — top-level success != per-key success
for rec in batch.batch_records:
if rec.result != 0:
raise map_aerospike_error("put-segment", _result_to_exc(rec.result)) - THEN commit the meta record LAST (`mbins` has `state="ready"`, no `b`):
`self._client.put(meta_key, mbins, meta=wmeta, policy=write_policy)`.
- Map exceptions:
RecordTooBig→AerospikeRecordTooBigErrorwith payload size + caps; code 22 on TTL →AerospikeTTLConfigError;KEY_BUSY/ overload →AerospikeBusyError; timeout on write → raise (caller retries). async def put(self, key, memory_obj):await self._run(self._put_sync_impl, key, memory_obj).- Do NOT call
ref_count_down()—InstrumentedRemoteConnector.putdoes it. - Do NOT retain
memory_objreferences after return.
- Do NOT call
- Add
_result_to_exc(result_code) -> Exception: tiny helper that wraps a per-record batch result code into somethingmap_aerospike_errorcan classify (carry.code).
Verify (gate): Unit tests with a fake client recording calls:
- 256 B payload → exactly one
put(single record,bpresent), nobatch_write; - payload forcing
nseg=3→ onebatch_writewith aBatchRecordsof 3Writerecords (segments) THEN oneputfor meta withstate="ready"and nob; verify segment byte slices reassemble to the original; - a fake
batch_writethat setsbatch_records[1].result=14→AerospikeBusyError; RecordTooBigfrom the fake client →AerospikeRecordTooBigError.
Do:
support_batched_get -> True;async def batched_get(self, keys): dedupe defensively, then underself._batch_semgatherself.get(k)for each key; return results in the SAME order as input (map dedup back).support_batched_put -> True;async def batched_put(self, keys, memory_objs): gatherself.put(k, mo)pairwise under the semaphore. Independent puts.support_batched_contains -> True;def batched_contains(self, keys) -> int(SYNC):- dedupe while preserving first-occurrence order is NOT needed here because we
need consecutive-prefix semantics on the ORIGINAL order — build
meta_keys = [meta_key(k) for k in keys]. brs = self._client.batch_read(meta_keys, [])(empty bins = metadata only). ⚠ DESIGN-CORRECTION: replacesexists_many.- Walk
brs.batch_recordsin order; count consecutiverec.result == 0(record found). Return the count at the first miss. (A present meta is treated as ready per the atomicity protocol.) - On any exception → return
0(the upstreamRemoteBackend.batched_containswraps this in try/except and treats failure as 0 anyway, but be explicit).
- dedupe while preserving first-occurrence order is NOT needed here because we
need consecutive-prefix semantics on the ORIGINAL order — build
support_batched_async_containsalready True in base; overrideasync def batched_async_contains(self, lookup_id, keys, pin=False) -> int:return await self._run(self._batched_contains_sync, keys)where_batched_contains_syncis the body of step 3. Ignorepinin Phase 1 (document it; upstream FS connector ignores it too).support_batched_get_non_blockingalready True in base; the base implementation (gather + release-on-failure) is correct for us, BUT it callsself.getper key without the semaphore. Override to add the semaphore and keep identical release semantics:results = await asyncio.gather(*(self._sem_get(k) for k in keys), return_exceptions=True)- then replicate the base loop: append consecutive
MemoryObjs; on firstNone/Exception, setfound_failure=Trueandref_count_down()every subsequentMemoryObj. Return the prefix list.
def remove_sync(self, key) -> bool:- read meta first to learn
nseg(best-effortselect(mk, ["nseg"])). self._client.remove(meta_key)→RecordNotFound⇒ return False.- if
nseg > 1: best-effortbatch_writeofbr.Remove(segment_key(i))for each segment; failures here are logged WARNING and ignored (TTL cleans up). ⚠ CE: do NOT passdurable_delete=True(EE-only). - return True on meta removal success.
- read meta first to learn
async def list(self) -> List[str]:- if not
cfg.enable_list: log INFO once and return[]. - if enabled:
scan = self._client.scan(namespace, set_name); collect user keys whose suffix is|m; strip the|m; returnkey.to_string()values. Mark as expensive/debug-only. Run via executor.
- if not
support_ping -> True;async def ping(self) -> int:def _ping(): return 0 if (self._client.is_connected() and self._client.get_node_names()) else 1; dispatch via executor; on any exception return1.
Verify (gate): Unit tests:
batched_contains([T,T,F,T]) == 2(fakebatch_readreturns results[0,0,2,0]where 2 = RECORD_NOT_FOUND);batched_getpreserves order with a duplicate key in the input;batched_get_non_blockingreleases trailing MemoryObjs after a None (assertref_count_downcalled on them);remove_syncremoves meta then issues segment removes fornseg>1;list()returns[]when disabled.
Goal: The class LMCache loads via module_path/class_name. It is
instantiated with no arguments, then create_connector(context) is called.
Do:
# file sketch: src/lmcache_aerospike/adapter.py
from lmcache.logging import init_logger
from lmcache.v1.storage_backend.connector import (
ConnectorAdapter, ConnectorContext, extract_plugin_type,
)
from lmcache_aerospike.config import AerospikeConfig
from lmcache_aerospike.client import AerospikeClientHolder
from lmcache_aerospike.connector import AerospikeRemoteConnector
logger = init_logger(__name__)
class AerospikeConnectorAdapter(ConnectorAdapter):
def __init__(self) -> None: # ⚠ NO required args
super().__init__("aerospike://")
def can_parse(self, url: str) -> bool:
if url.startswith("aerospike://"):
return True
if url.startswith("plugin://"): # LMCache passes plugin://{plugin_name}
return extract_plugin_type(url[len("plugin://"):]) == "aerospike"
return False
def create_connector(self, context: ConnectorContext):
# ⚠ canonical upstream pattern: prefer local_cpu_backend.config/metadata
lcb = context.local_cpu_backend
config = getattr(lcb, "config", None) or context.config
metadata = getattr(lcb, "metadata", None) or context.metadata
plugin_name = context.plugin_name or "aerospike"
as_cfg = AerospikeConfig.from_extra_config(
config.extra_config if config else None, plugin_name
)
holder = AerospikeClientHolder.get_or_create(as_cfg)
return AerospikeRemoteConnector(
config=config,
metadata=metadata,
local_cpu_backend=lcb,
loop=context.loop,
aerospike_config=as_cfg,
client_holder=holder,
)Notes / gotchas:
plugin_nameis the instance name (aerospikeoraerospike.primary); pass it straight toAerospikeConfig.from_extra_configso instance-scoped config keys resolve.- If
create_connectorraises (e.g. discovery fails),RemoteBackendcatches it, logs, and will retry permin_reconnect_interval. That is acceptable — fail loud, not silently. - Restore the
from lmcache_aerospike.adapter import AerospikeConnectorAdapterline in__init__.pynow (S1).
Verify (gate): Unit test:
AerospikeConnectorAdapter()constructs with no args;can_parse("plugin://aerospike")andcan_parse("plugin://aerospike.primary")are True;can_parse("redis://x")is False;create_connectorwith a fake context (mocklocal_cpu_backendhaving.config/.metadata, mocked holder + discovery) returns anAerospikeRemoteConnector.
Goal: Zero hard dependency on prometheus_client; metrics only register if
it is importable.
Do:
try: import prometheus_client … except ImportError: prometheus_client = None.- If available, define module-level collectors (created once):
aerospike_op_total{op,result}Counteraerospike_op_latency_seconds{op}Histogram (buckets 1ms..10s)aerospike_segment_countHistogramaerospike_segment_bytesHistogramaerospike_concurrent_in_flightGauge
- Provide no-op fallbacks (functions that do nothing) when
prometheus_clientis None, so the connector body callsmetrics.observe_op(...)unconditionally. - Wire calls from the connector: increment
op_totalwith the rightresultbucket (hit/miss/ok/timeout/record_too_big/busy/error) and time each op. Keep this lightweight; never let a metrics error break an op (wrap in try/except).
Verify (gate): Unit test: import metrics with prometheus_client absent
(monkeypatch sys.modules) and confirm observe_op(...) is a no-op that does
not raise; with it present, confirm counters increment.
Goal: Every module's gate test lives under tests/unit/ and the whole suite
passes with no Aerospike server.
Do:
- Create a shared fake client in
tests/unit/conftest.py:FakeMemoryObj: wraps abytearray; implementsbyte_array(memoryview),get_size,get_shapes,get_dtypes,get_memory_format,ref_count_down(increments a counter),meta.shape,meta.dtype.FakeLocalCPUBackend:.allocate(shapes, dtypes, fmt)returns aFakeMemoryObjsized from shapes×dtype itemsize (or a fixed size for tests); carries.configand.metadata.FakeClient: recordsput/batch_write/batch_read/select/get/remove/info_random_nodecalls; configurable return values and per-record results; raises configuredaerospike.exceptiontypes.FakeBatchRecord(result, record)and aFakeBatchRecords(list)exposing.batch_records.
- Provide canned
info_random_noderesponses (the three from S8b). - Ensure
RemoteMetadataworks in tests by callinginit_remote_metadata_info(num_groups)in a fixture (num_groups from the fake metadata'sget_num_groups()); setnum_groups=1for the basic tests. - Port every per-step gate test here. Add these cross-cutting tests:
- Atomicity: simulate a crash between segment writes and meta commit
(fake client: segments present, meta absent) →
getreturns None. batched_containsparity:[T,T,F,T] -> 2.- Error matrix: every row of S3 maps correctly.
- Round-trip in-memory: put then get through the fake client for sizes
that exercise
nseg=1andnseg=3, asserting byte-exact recovery.
- Atomicity: simulate a crash between segment writes and meta commit
(fake client: segments present, meta absent) →
- Run
pytest tests/unit -q.
Verify (gate): pytest tests/unit -q is fully green. No network access
occurs (no real aerospike.client().connect() in unit tests).
Goal: A reproducible single-node Aerospike CE with a lmcache namespace and
nsup-period > 0 so TTL writes succeed.
Do:
docker/aerospike.conf— minimal CE config. Key requirements:- a namespace named
lmcache; nsup-period 120(⚠ MUST be> 0or positive-TTL writes fail with code 22);default-ttl 0(let the client decide TTL);- storage-engine memory (or a small device file) sized for tests;
- on Aerospike 7.1+, optionally set
max-record-size 1M(so the discovery test has a known cap to assert).
- a namespace named
# file: docker/aerospike.conf (CE; adjust to the image's base config)
service { }
logging { console { context any info } }
network {
service { address any; port 3000; }
heartbeat { mode multicast; multicast-group 239.1.99.222; port 9918; interval 150; timeout 10; }
fabric { port 3001; }
info { port 3003; }
}
namespace lmcache {
replication-factor 1
nsup-period 120
default-ttl 0
storage-engine memory { data-size 1G }
# On 7.1+, uncomment to fix a known cap for the discovery test:
# max-record-size 1M
}
docker/docker-compose.yml:
# file: docker/docker-compose.yml
services:
aerospike:
image: aerospike/aerospike-server:latest
container_name: lmcache-aerospike-ce
ports: ["3000:3000", "3001:3001", "3003:3003"]
volumes:
- ./aerospike.conf:/etc/aerospike/aerospike.conf:ro
command: ["--config-file", "/etc/aerospike/aerospike.conf"]- Add
tests/integration/conftest.pythat:- is skipped entirely unless
RUN_INTEGRATION=1is set in the env; - starts compose (or assumes it is already up — document both), waits until
info_random_node("build")succeeds (poll up to ~30s); - yields a connected
aerospike.Clientand tears down.
- is skipped entirely unless
Verify (gate):
docker compose -f docker/docker-compose.yml up -dstarts the container.python -c "import aerospike; c=aerospike.client({'hosts':[('127.0.0.1',3000)]}).connect(); print(c.info_random_node('namespace/lmcache')); c.close()"prints a config string containingnsup-period=120and (on 7.1+)max-record-size=....
Goal: Prove the connector works end-to-end against a real CE node.
Do: Implement these tests in tests/integration/ (all skipped unless
RUN_INTEGRATION=1). For each, build a real AerospikeConfig pointing at
127.0.0.1:3000, namespace lmcache, set it_chunks, and a real
LocalCPUBackend from a minimal LMCacheEngineConfig + LMCacheMetadata
(construct the smallest valid metadata; if that is hard, use a tiny real
LMCache engine init helper).
- Discovery log/values: construct the connector; assert
connector._resolved.max_segment_bytes == server_cap - 65536and that the INFO discovery lines were emitted. - Round-trip size matrix: for payloads
256B, 64KiB, 1MiB, 4MiB, 16MiB, 64MiB: put then get; assert byte-exact; assertmeta["nseg"]matches the expected shard count for the discovered cap. - TTL expiry:
default_ttl_seconds=2; put;sleep(5);get→ None;exists→ False. - Pinned key: put with TTL
-1(pinned path);sleep(default_ttl+slack); assert still present. (Requires building the put-pinned path; if pin is not exposed in Phase 1 public API, test via a direct_put_sync_implwith a pinned flag.) - Crash mid-write: write segments only (call segment batch_write), do NOT
write meta;
get→ None; then confirm TTL eventually removes segments. - TTL/NSUP precondition: point at a namespace with
nsup-period 0(a second namespace or reconfigured container) anddefault_ttl_seconds>0; assert construction raisesAerospikeTTLConfigError(fail fast, not on first write). - Multi-instance: two configs (
aerospike.primary,aerospike.dr) to the same node but different sets; write to one, miss from the other. - Layerwise/MLA smoke (⚠ gap from review): if feasible, build metadata with
use_layerwise=True(forcessave_chunk_meta=True); round-trip aLayerCacheEngineKey-shaped payload; assert byte-exact. Document if deferred.
Verify (gate): RUN_INTEGRATION=1 pytest tests/integration -q is green
against the S13 container. Capture the discovery INFO lines in test output.
Goal: Numbers to confirm the 4 MiB default behaves well on the target
deployment and to confirm real cache reuse. These gate the move from 0.1.x
alpha to 0.2.0 stable.
Do (bench, tests/bench/):
pytest-benchmark-driven synthetic chunk stream. Keep the defaulttarget_segment_bytes = 4 MiBas the primary configuration (LMCache authors' MB-scale sweet spot, arXiv:2510.09665) and ALSO sweep neighbors (1MiB, 2MiB, 4MiB, 8MiB) purely as a sensitivity check, on a payload band representative of the target model (e.g. Llama-class TP=8 chunks).- Measure and print:
getp50/p95/p99 (100% hit and 100% miss),putp50/p95/p99, sustained bytes/s underbatch_max_in_flight=64, connector thread-pool CPU%. - Decision output: a short table across segment sizes. The default stays
4 MiB; only recommend deviating for a specific deployment if it is proven
device-saturated (per DESIGN §4.6's fallback recipe: halve to 2 MiB, then
1 MiB, never below
min_segment_bytes). Record any deployment-specific override and its measured rationale; do NOT change the shipped default.
Do (vLLM smoke, optional but recommended):
- A pytest fixture launches vLLM + LMCache configured with this connector and a
tiny model (e.g. Llama-3.2-1B). Send two identical long prompts across a
worker restart; assert the second request's TTFT is materially lower (cache
hit through Aerospike). Gate behind
RUN_VLLM=1and document GPU/CPU needs.
Verify (gate): Bench runs and emits the comparison table; vLLM smoke (if run) shows TTFT drop on the second prompt.
Goal: Land CI, user docs, and fold the plan's corrections back into
DESIGN.md so the two documents agree.
Do:
- CI (
.github/workflows/ci.yml): matrix Python 3.10/3.11/3.12/3.13 on Linux x86_64. Jobs: lint +pytest tests/uniton every PR; a separate job runningRUN_INTEGRATION=1 pytest tests/integrationwith the compose service (use a service container ordocker compose up -d) on merge tomain. - README: install (
pip install lmcache lmcache-aerospike), the YAML config snippet (from DESIGN §4.5.1 but with the corrected defaulttarget_segment_bytes), and the compatibility matrix. - DESIGN.md reconciliation — apply these edits (so design == implementation):
- §1/§4.4.0/§4.3.6: discovery runs at connector construction, not
post_init. - §4.4.4 / §4.4.10:
batch_write(BatchRecords([...])), not tuple lists. - §4.4.7:
batch_read(meta_keys, [])for existence, notexists_many. - §4.3.1: store one
md(RemoteMetadata.serialize()) blob bin; honorsave_chunk_meta; drop the rigidshape0..shape3bins. - §4.4.3: allocate from stored metadata when
save_chunk_meta, else fromself.meta_*+reshape_partial_chunk— remove the contradiction. - §2.2: fix the Aerospike sweet-spot to "1–10 KiB" (match §4.6 and skills);
cite Aerospike docs for the 8 MiB cap and the 7.1+
max-record-sizedefault of 1 MiB. Keep this separate from the LMCache chunk sizing below. - §4.3.4 / §4.5.2: keep
target_segment_bytes = 4 MiBas the default and cite the LMCache paper (arXiv:2510.09665) for the MB-scale chunk-transfer sweet spot. Add one sentence clarifying these are two different "sweet spots": Aerospike's 1–10 KiB record sweet spot is ops-throughput guidance, while LMCache's MB-scale chunk is byte-throughput guidance; the connector deliberately follows the LMCache value and clamps it to the server's record-size cap. - Add a note that the connector is serde-agnostic and that MLA/layerwise key
rewriting happens in
RemoteBackendabove the connector. - Pin the
aerospikeclient version and note themeta["ttl"]deprecation boundary (v19.1.0).
- §1/§4.4.0/§4.3.6: discovery runs at connector construction, not
Verify (gate): CI is green on a PR (unit) and on main (integration);
README install works in a clean venv; DESIGN.md no longer contradicts this
plan (grep for exists_many, post_init, shape0, 1-128 KiB and confirm
each is updated).
Phase 3 is implemented after Phase 1 and Phase 2, using LMCache's native connector protocol rather than a Python executor hot path. This addendum is the source of truth for the native implementation direction.
Adopt these native RESP techniques:
- C++ worker tiling for batched set/get/exists/delete.
- pybind methods that release the GIL after extracting key strings and memoryview pointers.
- A single pollable completion fd and one completion per submitted batch.
- Per-key result bits for lookup/load/delete so LMCache can represent partial batch success.
- Direct copies between Aerospike C client byte values and LMCache-provided
buffers; no Python
byteshop on the hot path.
Do not adopt Redis' raw schema as the initial Aerospike native schema. Phase 3 starts compatibility-first: native records use the existing Phase 1/2 meta+segment layout, including the inline single-record fast path and sharded fallback for payloads above the discovered Aerospike record cap.
- Add
csrc/aerospike/connector.h,csrc/aerospike/connector.cpp, andcsrc/aerospike/pybind.cpp. - Add
src/lmcache_aerospike/native_connector.pyas the Python factory class used by LMCachenative_plugin. - Update
pyproject.tomland addsetup.pyif setuptools extension wiring is needed forpybind11andlibaerospike. - Add native L2 adapter JSONs under
benchmarks/l2/adapters/. - Update
benchmarks/l2/run.sh,benchmarks/l2/compare.sh, andbenchmarks/l2/README.mdsoaerospike-nativecan be benchmarked against Redisresp. - Add unit tests and gated
RUN_NATIVE=1integration tests.
The compatible schema remains the default until benchmarks prove it is a top bottleneck. If a raw native schema becomes worthwhile, choose one of two paths:
- Add an explicit raw native mode that is documented as incompatible with Phase 1/2 records.
- Align Phase 1, Phase 2, and Phase 3 to the faster schema together, with migration or dual-read coverage.
Do not silently make Phase 1/2 configs write one schema while native L2 writes another schema under the same backend name.
pytest tests/unit -qRUN_NATIVE=1 pytest tests/integration -qwhenlibaerospikeand Aerospike CE are available../benchmarks/l2/run.sh --backend aerospike-native./benchmarks/l2/run.sh --backend resp./benchmarks/l2/compare.sh --nativeor the equivalent sequential comparison command added by Phase 3.
If Aerospike native is still materially slower than Redis, inspect benchmark output before changing schema. First optimize worker counts, Aerospike C client policies, batch shape, batch API usage, and per-op allocation overhead.
Mirrors DESIGN §1.5, with corrections.
-
pip install -e ".[dev]"works on Python 3.10–3.13;import lmcache_aerospikeclean. -
pytest tests/unit -qfully green (no network). - Discovery runs at construction; bad namespace/
nsup-period 0/out-of-range cap fail fast with the typed errors (proven by integration tests). - Round-trip byte-exact for CI sizes (512B, 64KiB); larger sizes via
RUN_LARGE_INTEGRATION=1locally (1MiB–64MiB). -
batched_containsconsecutive-prefix semantics (unit + integration coverage). - TTL expiry and pinned (
-1) behavior verified on a real CE node. - No use of
exists_many/get_many/select_many; batch ops usebatch_read/batch_write+BatchRecords. - TTL set in exactly one helper; client version pinned.
-
save_chunk_metahonored; layerwiseLayerCacheEngineKeyintegration smoke. - vLLM smoke shows cache reuse across a worker restart (optional;
RUN_VLLM=1). - Bench harness:
benchmarks/run.py(ecosystem) +benchmarks/micro/(pytest-benchmark). -
DESIGN.mdreconciled with this plan (v0.2 status onmain).
RemoteConnectorabstract set andsupport_*defaults:lmcache/v1/storage_backend/connector/base_connector.py.- Adapter loading, no-arg adapter instantiation,
plugin://{name}URL,ConnectorContext:…/connector/__init__.pyand…/storage_backend/remote_backend.py(init_connection) and…/storage_backend/__init__.py(CreateStorageBackends). post_initis defined+forwarded but NOT called in the remote path: confirmed acrossremote_backend.py,storage_manager.py,instrumented_connector.py.- Serde applied above connector:
RemoteBackend.__init__(CreateSerde) andsubmit_put_task/get_blocking. save_chunk_metahandling pattern:…/connector/fs_connector.py(put/get),RemoteMetadatain…/v1/protocol.py.- Aerospike client API (
batch_write/batch_read/info_random_node/putTTL): official Python client reference (pin a version; legacy*_manyremoved).