Status: implemented · Tracks: InferenceCache tech spec §4.1 · API group: inferencecache.io/v1alpha1
CacheBackend is the namespaced CRD that describes an engine-side cache
implementation, an optional remote-storage tier, and the engine integration
policy that should use them. Provider lifecycle belongs to storage-provider
adapters; runtime adapters own engine Pod wiring only.
| Value | |
|---|---|
| group | inferencecache.io |
| version | v1alpha1 |
| kind | CacheBackend |
| plural | cachebackends |
| short name | cb |
The v1alpha1 contract is pre-launch and explicitly unstable (see the carve-out paragraph below for the precise terms); after the v1beta1 promotion, new fields must be additive and tightening validation on existing fields requires a versioned migration path.
Pre-launch carve-out (active until v1beta1). The project is pre-launch and v1alpha1 is explicitly unstable: where keeping an inert, unidiomatic, or operator-confusing field through to v1beta1 would compound the cleanup work, a per-change waiver allows in-place removal during alpha. Each such removal is gated on (1) a locked design decision naming the field and the reason, (2) zero current consumers (no external operator manifests, no cross-component code), and (3) replacement of the operator-facing surface where one existed. Closed precedent: CacheTenant.spec.quota.maxMemoryBytes and status.memoryUsed removed (we cannot enforce per-tenant byte budgets on shared engines, and the underlying observation would be double-counted across tenants). The cluster-aggregate sibling CacheIndex.status.tenants[].memoryUsed has the same honesty problem (summing per-tenant memory across replicas on a shared engine double-counts the same bytes once per tenant), but because it is a published v1alpha1 status field it is deprecated and zeroed in place rather than removed: the controller stops populating it (always 0) and operators are redirected to the per-replica CacheIndex.status.replicas[].cacheMemoryBytes (engine total per replica, honest at that altitude), while the field stays in the schema for wire/shape compatibility until its removal at v1beta1. Current applied removals: CacheBackend.status.health and the CacheBackendHealth enum removed in favour of the standard status.conditions[Ready|Degraded|Progressing] surface (the old Degraded health value is replaced by Conditions[Degraded]), which the new Ready printer column displays; and CacheBackend.spec.storage{,.pvc} + status.capacity removed — the lm:// LMCache server we provision is in-memory, so a local PVC could not honestly back it, and durability is expressed as a backend choice (the Mooncake backend, now implemented — see Mooncake provider configuration) rather than a generic volume knob (locked decision: docs/design/lmcache-server-persistence.md; replacement surface: backend-type selection). Once v1beta1 is promoted, this carve-out is closed: subsequent breaking changes require a versioned migration.
The canonical API assigns one architectural dimension to each field:
spec:
runtime: SGLang
type: LMCache
lmCache:
chunkSizeTokens: 256
hostMemory:
capacity: 32Gi
remoteStorage:
provider: Redis
ownership: Managed
redis:
image: docker.io/library/redis:7.4-alpine
resources:
limits:
memory: 8Gi
observation:
modelID: Qwen/Qwen3runtimeselects the inference runtime.typeselects only the engine-side cache implementation.lmCacheandhiCacheconfigure local/host cache behavior.remoteStorage.providerselects the optional remote technology.remoteStorage.ownershipselects controller-managed or external lifecycle.- Provider-specific workload settings live below their provider object.
observationowns event-observation identity and timing.
Omitting remoteStorage is meaningful and never selects infrastructure:
spec:
runtime: SGLang
type: LMCache
lmCache:
hostMemory:
capacity: 32GiThis requests SGLang -> LMCache host memory only. The controller creates no
provider Deployment or Service, and the engine adapter injects the node-local
LMCache MP worker without an L2 adapter.
Capability resolution is deliberately two-dimensional:
runtime + type provider + ownership
| |
v v
engine-wire adapter storage-provider adapter
| |
+--------- optional Binding -------+
The provider adapter owns workload and Service rendering and emits a structured
binding (lm, resp, or mooncakestore). The engine adapter declares which
bindings it accepts. Admission rejects unsupported combinations before an
engine Pod is created.
spec.type is a closed CRD enum containing LMCache and SGLangHiCache.
Remote-provider technology and lifecycle ownership are not cache types:
Mooncake is selected through remoteStorage.provider, and externally managed
infrastructure through remoteStorage.ownership. The API server rejects the
old type: Mooncake and type: External spellings before admission.
The canonical External and Mooncake examples are available in
config/samples/cachebackend-external.yaml
and config/samples/cachebackend-mooncake.yaml.
| Field | Type | Purpose |
|---|---|---|
runtime |
enum | Required inference runtime: VLLM or SGLang. Values are case-sensitive. |
type |
enum | Engine-side cache implementation: LMCache or SGLangHiCache. Defaults to LMCache. |
lmCache |
object | Typed LMCache engine configuration: chunk size, host-memory capacity, MP-worker image/port, and remote serde. |
remoteStorage |
object | Optional remote tier. Omitting it means host-only and provisions no provider workload. |
remoteStorage.provider |
enum | Redis, LMCacheServer, or Mooncake. |
remoteStorage.ownership |
enum | Managed or External. |
remoteStorage.endpoint |
string | Required for External, rejected for Managed; managed endpoints are controller-observed in status. Bare host:port is portable across all providers. LMCacheServer also accepts lm://host:port, Mooncake also accepts mooncakestore://host:port, and Redis accepts only bare host:port. Every provider requires a numeric port in 1-65535; admission rejects schemes belonging to another provider. |
remoteStorage.redis |
object | Redis-owned image and resource configuration. |
remoteStorage.lmCacheServer |
object | Standalone LMCache-server-owned image, command, and resource configuration. |
remoteStorage.mooncake |
object | Mooncake-owned image, command, and resource configuration. |
observation |
object | Observation-owned modelID and firstEventTimeout. |
deploymentKind |
enum | Managed workload kind: Deployment or StatefulSet. Defaults to Deployment. |
replicas |
integer | Desired managed backend replicas. Defaults to 1. Minimum 0. See Defaulting for the interaction with spec.autoscaling.minReplicas (first-apply-only). |
autoscaling.minReplicas |
integer | Lower bound for HPA replica count. Auto-defaulted to spec.replicas on FIRST APPLY ONLY by the admission defaulter when spec.autoscaling is set and minReplicas is left unset (see Defaulting for the first-apply-only semantics); subsequent edits to spec.replicas do NOT move this floor. Minimum 1. |
autoscaling.maxReplicas |
integer | Upper bound for HPA replica count. Required when autoscaling is set. Minimum 1. Cross-field validation: minReplicas <= maxReplicas. |
autoscaling.targetCPUUtilizationPercent |
integer | Target average per-pod CPU utilization for the HPA. Defaults to 80 when unset. Range [1, 100]. |
integration.mode |
enum | Which cache tiers the engine is wired for: Offload (default) or EventsOnly. Offload is full participation — cache-aware routing (tier-1) plus the KV-offload connector (tier-2). It may remain host-only, connect to externally owned remote storage, or provision a provider workload when remoteStorage.ownership is Managed. EventsOnly wires routing only: the kvevent-subscriber sidecar is injected when the controller runs with --kvevent-subscriber-image set and observation.modelID is present; otherwise the append is skipped fail-open. No KV connector or backend server is created. See Events-only mode. |
integration.role |
enum | Engine participation mode: ReadOnly, WriteOnly, or ReadWrite. Defaults to ReadWrite. |
integration.failOpen |
boolean | Default true. When true, engine pods fall back to local prefill on cache unreachability — the cache is an optimization, never a serving dependency. Setting it to false is an advanced opt-in to fail-closed serving (the cache becomes a serving dependency); the controller surfaces this as a Warning Kubernetes Event on the owning CacheBackend. Pair-specific exception — (sglang, LMCache): SGLang has no cacheless code path while --enable-lmcache is on, so its co-scheduled MP worker is a serving prerequisite (a worker that never starts wedges the engine), not a remote dependency that degrades to local prefill. failOpen is still honored at the tier that can actually be "unavailable" — the shared L2 (the worker comes up L1-only when Redis is unreachable). This is a documented, accepted boundary; see the fail-open semantics in sglang-lmcache-mp-mode.md and SGLang engine support. |
integration.engineOverrides |
object | Optional engine-injection overrides applied to the args/env the pod-mutating webhook would otherwise inject into the engine container. See Engine-injection overrides. |
engineSelector.matchLabels |
map | Equality-based label selector matched against engine pod labels (the pod template's metadata.labels, not Deployment, DaemonSet, or any other workload-level labels). Every key/value here must appear on the pod for it to match. matchExpressions is intentionally not exposed in v1alpha1 — the surface is matchLabels only. |
hiCache |
object | Typed SGLang native HiCache configuration. Required only for type: SGLangHiCache; see SGLang native HiCache. |
template |
object | Optional pod-level overrides for managed backend pods. This is a narrow override surface, not a full PodSpec; backend containers come from controller defaults. |
allowCrossNamespace |
boolean | Opt-in flag that allows spec.remoteStorage.endpoint to resolve to a Kubernetes Service in a different namespace from the CacheBackend itself. Without it, admission rejects cross-namespace Service-DNS endpoints. External hostnames and IPs are unaffected. Defaults to false. |
Per-namespace lookup tuning lives on CachePolicy, not CacheBackend. The lookup latency budget and the minimum-prefix-token gate are configured via
CachePolicy.spec.lookupTimeoutMsandCachePolicy.spec.minimumPrefixTokens, which are the surfaces actually wired into the server'sResolvedPolicyand theLookupRoutepath.
spec.template supports partial pod-level overrides that can be merged with managed backend defaults:
nodeSelectoraffinitytolerationstopologySpreadConstraintsimagePullSecretsserviceAccountNamesecurityContextpriorityClassNameschedulerNameruntimeClassNameterminationGracePeriodSeconds
It intentionally does not expose containers; requiring users to provide containers would conflict with managed backend defaults and would make simple scheduling overrides unnecessarily large.
Canonical resources place corev1.ResourceRequirements under the provider that
owns the workload: remoteStorage.redis.resources,
remoteStorage.lmCacheServer.resources, or
remoteStorage.mooncake.resources. The provider renderer deep-copies that
block onto its managed container. If the typed block is omitted, the provider
uses a bounded 4Gi request / 8Gi limit without persisting a default into the CR.
Pass-through to the rendered container. The provider adapter DeepCopy's
the selected typed resource block onto Container.Resources. The deep copy is
load-bearing: the reconciler reads from an informer cache, and writing through
the spec pointer would corrupt the cached object for every subsequent reader.
An explicit empty provider resources: {} suppresses the provider default.
redis-l2: the memory limit also sizes the L2 keyspace. The rendered Redis
provider derives --maxmemory from
remoteStorage.redis.resources.limits.memory at roughly 80%, with
allkeys-lru.
Autoscaling CPU-request fallback. A targetCPUUtilizationPercent HPA needs a positive CPU request as the denominator for its utilization math, so when spec.autoscaling is set the adapter fills in cpu: 250m whenever the selected provider resource block's requests.cpu is absent OR non-positive. The non-positive case matters because the admission validator admits requests.cpu: "0" as a valid kubelet shape (an explicit "no guaranteed minimum" for non-autoscaled pods); without the autoscaling-side replacement, the HPA would dial against a 0 denominator. A positive operator-supplied value (e.g. requests.cpu: "1") survives untouched. The fallback is CPU-only — it never synthesises a memory request — and the operator-supplied memory block (or the legacy webhook/provider default) flows through unchanged.
resources.claims is rejected at admission. corev1.ResourceRequirements also exposes a Claims slice for Dynamic Resource Allocation (DRA), but the renderer does not plumb the matching pod-level spec.resourceClaims — a claim-bound container.resources.claims would render a pod the apiserver rejects (claim name doesn't resolve at the pod level). The validating webhook (rejectResourceClaims) hard-rejects non-empty claims until DRA is wired end-to-end; a nil/empty claims slice admits unchanged.
Request/limit relationship is resource-aware. The validating webhook (rejectResourceLimitsBelowRequests) enforces K8s' two-regime contract:
- Overcommittable resources (
cpu,memory,ephemeral-storage):limits[X]must be>= requests[X]when both are set. Memory is the motivating case (an inverted memory limit deepens the OOM-kill cliff this field exists to close), and the rule catches CPU typos with the same diagnostic. - Non-overcommittable resources (
hugepages-*and vendor-prefixed extended resources like"nvidia.com/gpu"):limits[X]must EQUALrequests[X]when both are set. K8s does not allow overcommitting these — every page or device is dedicated — so request and limit must agree.
Limits-only shapes admit unchanged for any resource — K8s auto-populates requests from limits. Requests-only is admitted only for overcommittable resources (cpu, memory, ephemeral-storage); a non-overcommittable resource declared in requests without a matching limits entry is rejected (rejectRequestsOnlyForNonOvercommittableResources), because K8s requires hugepages and extended resources to declare both halves together.
Extended-resource quantities must be integers. Vendor-prefixed extended resources (e.g. nvidia.com/gpu) are allocated by whole units — K8s rejects a fractional shape like nvidia.com/gpu: 500m on the rendered Pod. The validating webhook (rejectFractionalExtendedResources) mirrors that rule at admission, so the operator sees a field-scoped error at kubectl apply. Standard overcommittable resources (cpu, memory, ephemeral-storage) admit fractional values — 250m is the canonical kubelet CPU shape and is unaffected.
Hugepage quantities must align to the page size. The Linux kernel allocates hugepages in whole-page chunks, so K8s rejects a misaligned shape like hugepages-2Mi: 3Mi (3Mi isn't a multiple of 2Mi). The validating webhook (rejectMisalignedHugepageQuantities) parses the page size from the resource name's suffix and rejects any positive quantity that is not a whole multiple of that page size. Zero quantities admit trivially (no allocation) and negative quantities are caught upstream by rejectNegativeResourceQuantities.
Quantities must be non-negative. The CRD-schema layer treats each requests/limits entry as a resource.Quantity string and admits a leading - without complaint. The kubelet only flags the negative quantity once the child pod tries to schedule — by which time the operator is chasing it through Deployment events. The validating webhook (rejectNegativeResourceQuantities) rejects any strictly-negative entry at admission so the regression surfaces at kubectl apply instead. Zero is admitted (an operator who writes requests.memory: "0" is explicitly opting into "no guaranteed minimum", which matches the kubelet's >= 0 contract).
Resource names must match K8s container-resource rules. ResourceList keys are opaque map keys at the CRD-schema layer; an invalid name like "foo" or "" persists in etcd and only fails when the apiserver later rejects the child pod. The validating webhook (rejectInvalidResourceNames) applies the same rules the apiserver applies to a Container.Resources map: standard names (cpu, memory, ephemeral-storage) admit unconditionally; a hugepages-<size> name admits only when the size suffix parses as a strictly-positive resource.Quantity (e.g. "hugepages-2Mi", "hugepages-1Gi" — a bare "hugepages-" or non-numeric "hugepages-nope" is rejected because the apiserver requires the size token); any other name must be third-party vendor-prefixed (e.g. "nvidia.com/gpu") and pass IsQualifiedName. A bare unqualified "foo" is rejected even though IsQualifiedName alone admits it, because the apiserver's container-resource layer requires extended resources to carry a vendor identity. Names under the K8s-reserved prefixes kubernetes.io/ and requests.kubernetes.io/ are also rejected — those prefixes are reserved for native resources, so extended resources may not use them. The rejection names the offending key so multi-key errors surface together.
Inert without a controller-managed workload. Host-only, externally owned,
and SGLangHiCache configurations provision no cache-server workload of their
own. HiCache host memory belongs to the user-owned engine container and must be
sized on that workload instead.
SGLang supports two peer cache integrations:
| Runtime/backend pair | Data plane | Controller-managed workload |
|---|---|---|
(SGLang, LMCache) without remoteStorage |
Node-local LMCache MP worker, host-only | None |
(SGLang, LMCache) with Managed Redis |
Node-local LMCache MP worker with a shared Redis remote tier | Redis Deployment and Service |
(sglang, SGLangHiCache) |
Native engine-local host cache | None |
SGLang drives LMCache in multiprocess (MP) mode (implemented, GPU-validated end to end). Unlike vLLM, SGLang reads LMCache config from a
--lmcache-config-file(carryingmp_host/mp_port), attaches to a node-local MP worker over ZMQ + a shared-memory data path, and offloads to a shared L2 store (the worker's--l2-adapter) — it does NOT use a cluster-reachablelm://server (lm://is not even a valid MP--l2-adaptertype). So the(sglang, LMCache)data plane differs from vLLM's on both halves, and the sections below reflect that. Authoritative design + validation evidence:sglang-lmcache-mp-mode.md.
SGLang is the second runtime the cache plane supports (spec.runtime: SGLang,
spec.type: LMCache; adapter at internal/adapters/builtin/runtime). Its engine
adapter configures the node-local MP worker and accepts either no binding
(host-only) or a RESP binding. The independent Redis provider adapter creates a
Redis workload only when spec.remoteStorage explicitly selects
provider: Redis, ownership: Managed.
Cluster prerequisite — Kubernetes ≥ 1.29 (REQUIRED for the SGLang MP wire). The MP worker is injected as a native sidecar — an
initContainersentry withrestartPolicy: Always, which K8s only understands from 1.29 (beta, on by default; stable 1.33). On an older cluster the apiserver does not recognize that field, so a(sglang, LMCache)engine pod fails admission (or the worker degrades to a plain init container that exits before the engine starts) rather than failing open — the one place this pair has a hard cluster-version floor. vLLM+LMCache and the routing-only path have no such floor. There is no in-webhook version gate today; operators on the SGLang pair must run 1.29+.
Two more caveats on the SGLang support surface (details below): (1) server-derived
LookupRoutewith rawtoken_ids/prompt_textonly hits when the server's single global--engine-block-sizematches SGLang's page size (see the "Block-size alignment" note later in this section); gateways that send pre-computedprefix_hash/block_hashesare unaffected. (2) Thelmcache-kernel-checkinit container is vLLM-only today (the SGLang adapter does not implementInitContainerProvider), soEngineKernelsHealthyis not published for SGLang pods.
The webhook renders the MP data plane on the SGLang engine pod. Alongside the
engine container it adds a node-local MP-worker native sidecar (an init
container with restartPolicy: Always) that writes the --lmcache-config-file
then runs the LMCache MP server on 127.0.0.1. With a RESP binding it appends
--l2-adapter and offloads to Redis; without a binding it runs host-only.
NVIDIA_VISIBLE_DEVICES=all
lets the GPU-less sidecar CUDA-IPC the engine's GPU with no device-plugin
allocation, an exec startup-probe on the loopback ZMQ port gates the engine's
start, and a shared emptyDir carries the config file. For /dev/shm (the L1 tier)
it reuses the engine's own volume when the engine already mounts one (a duplicate
mountPath is an invalid Pod), else adds a sized emptyDir{medium: Memory} — see the
reserved-names note below for the reuse/reject rules. On the engine container (name
sglang) it injects:
--enable-lmcache— SGLang's boolean flag (an argparsestore_true) that activates its LMCache connector. This replaces vLLM's--kv-transfer-configJSON.--lmcache-config-file <path>— points the engine at the MP config file the worker writes (mp_host/mp_port); MP mode aborts at startup without it.LMCACHE_USE_EXPERIMENTAL=True— gates SGLang's experimental LMCache integration; without it--enable-lmcachedoes not engage the connector.INFERENCECACHE_FAIL_OPEN=<true|false>— thespec.integration.failOpenmirror.
GPU visibility on the MP worker — an isolation trade-off to know about before running this on a shared node. The worker sidecar carries
NVIDIA_VISIBLE_DEVICES=all, so with the NVIDIA container runtime it can see every GPU on the node, not only the one its engine was allocated. It holds no device-plugin allocation (nonvidia.com/gpurequest), so it consumes no GPU from the node's allocatable — but the visibility is real, and on a node shared with other tenants' GPU workloads the worker is not confined to its own device.Why it cannot be narrowed. The worker moves KV by CUDA-IPC: the engine hands it a device UUID, which LMCache resolves to a local device index — and that resolution fails unless the device is visible to the worker process. Revoking visibility is GPU-validated as fatal, not degraded: the worker dies with
RuntimeError: Device UUID <uuid> not found in the discovered devicesand the engine never reaches ready. Scoping to just the engine's device is not available to a mutating webhook: the device plugin assigns the UUID at kubelet time, after admission runs, so there is nothing to narrow to yet. Giving the worker its ownnvidia.com/gpurequest is worse — it burns a second GPU and the scheduler would hand it a different device than the engine's.Scope of what the adapter adds. This is the engine image's own posture rather than something the adapter introduces: sglang images ship
NVIDIA_VISIBLE_DEVICES=allin theirENV, and the device plugin overrides it only for containers that request a GPU (the engine gets a specific UUID; a request-less sidecar keeps the image default). The adapter sets it explicitly so the wire also works on aworkerImagethat lacks that default, instead of depending on an image side effect. Operators who need hard GPU isolation between tenants should not co-schedule those tenants on one node — the same guidance that applies to any CUDA-IPC sidecar.
Names the MP wire reserves on the engine pod. The init container lmcache-mp-worker, the volumes lmcache-config + lmcache-dshm, and the mount path /etc/lmcache are adapter-owned. If the pod already carries one of them and the adapter did not render it, admission rejects the injection — which the pod webhook turns into a fail-open admit, so the pod starts un-wired (no cache) rather than with its own container silently overwritten. The same applies when the engine mounts /dev/shm read-only or from a configMap/secret/downwardAPI/projected volume: the MP data path writes there, so it is rejected at admission instead of failing deep inside LMCache at runtime. Rename the colliding object (or drop the readOnly) to get the pod wired. Re-injecting an already-wired pod is not a collision — the adapter recognises its own worker and converges it on the current render.
The old lm:// LMCACHE_REMOTE_URL / serde / chunk-size / local-CPU env is
NOT injected — SGLang MP mode ignores it. New manifests use typed
spec.lmCache fields:
| Field | Default | Bounds | Purpose |
|---|---|---|---|
lmCache.chunkSizeTokens |
256 |
>=1 |
The worker's --chunk-size and config-file chunk_size. |
lmCache.hostMemory.capacity |
4Gi |
positive quantity | Host-memory budget; rendered to the worker's whole-GiB L1 allocation. |
lmCache.workerPort |
5555 |
1–65535 |
Loopback ZMQ port used by the engine and worker. |
lmCache.workerImage |
engine image | — | Optional MP-worker image override. |
Deliberately not injected for SGLang (a real engine difference, not an omission): VLLM_USE_V1 (a vLLM-internal codepath with no SGLang analogue) and PYTHONHASHSEED (vLLM pins it to stabilise its builtin-hash()-seeded block-hash chain across TP workers; SGLang derives its prefix hash with hashlib.sha256 over the token-id bytes, independent of PYTHONHASHSEED).
spec.integration.role support. vLLM maps the role onto its LMCache connector's kv_role (ReadOnly→kv_consumer, WriteOnly→kv_producer, ReadWrite→kv_both). SGLang's --enable-lmcache integration has no kv_role split — it always both stores and retrieves — so a (sglang, LMCache) backend supports only ReadWrite (the default). Admission rejects ReadOnly / WriteOnly for SGLang (rejectUnsupportedSGLangRole) rather than silently treating them as ReadWrite; the rule lifts if SGLang's LMCache integration gains a producer/consumer split.
Reserved set (internal/adapters/builtin/runtime): ReservedArgs() = --enable-lmcache, --lmcache-config-file; ReservedEnv() = LMCACHE_USE_EXPERIMENTAL, INFERENCECACHE_FAIL_OPEN. In MP mode the old lm:// LMCACHE_REMOTE_URL is neither injected nor reserved. VLLM_USE_V1 / PYTHONHASHSEED are not reserved because they are never injected.
The two override surfaces are separate: spec.lmCache shapes the worker
sidecar, while spec.integration.engineOverrides edits the engine container's
args/env only.
KV-event source & hash_scheme: "sglang". SGLang adopted vLLM's KV-event wire wholesale — --kv-events-config drives a ZMQ ZmqEventPublisher emitting the same msgspec array-like BlockStored / BlockRemoved / AllBlocksCleared tuples (BlockStored carries token_ids, so the subscriber derives the same in-pod content fingerprint it does for vLLM). The shipped kvevent-subscriber binary therefore decodes SGLang's stream unchanged; the only difference is the adapter pins the sidecar's --hash-scheme=sglang. The index keys on (tenant, model, hash_scheme, adapter, prefix_hash), so SGLang prefixes occupy a domain disjoint from vLLM's: a request hashed under one scheme never false-hits a bytewise-identical entry recorded under the other, even when both engines tokenize the same text to the same token ids and the content fingerprints collide. As with vLLM, the operator must launch the engine with --kv-events-config '{"publisher":"zmq","endpoint":"tcp://*:5557","topic":"kv-events"}' for the publisher to be active — the adapter wires the cache offload, not the event publisher.
Block-size alignment for server-derived lookups (operational note). The subscriber-ingested path is block-size-safe by construction: the subscriber derives each prefix fingerprint in-pod using the
block_sizecarried on the engine's ownBlockStoredevent (SGLang's--page-size, often 64), so SGLang entries land in the index at SGLang's block size with no server involvement. The server-derived lookup path is the catch: when a gateway callsLookupRoutewith rawtoken_ids/prompt_text(rather than a pre-computedprefix_hash/block_hasheschain), the server fingerprints them with its single global--engine-block-size(default 16, vLLM's). For SGLang those server-side hashes only line up with SGLang-ingested entries when--engine-block-sizeis set to SGLang's page size. Because the flag is one global value, a single server cannot serve raw-token_idslookups for both a vLLM (16) and an SGLang (64) deployment at once — in a mixed-engine cluster, have gateways send the pre-computedprefix_hash/block_hashes(the subscriber/fingerprint path, which is block-size-correct per engine) for the raw-token path, or run a server per block size. Making--engine-block-sizeper-hash_schemeis a server change tracked as a follow-up; it is the first concrete latently-vLLM-centric assumption this second engine surfaced.
MP mode implemented (GPU-validated 2026-07). The once-open wire-test question — does SGLang honour
LMCACHE_REMOTE_URLfrom the env, or require a config file? — resolved to "neither the old way": SGLang ignores theLMCACHE_*env and reads config only from--lmcache-config-file, driving LMCache in MP mode, not thelm://remote-server model. The adapter now renders exactly that — config-file + node-local MP-worker sidecar + shared Redis L2 — validated end to end (store→flush→retrieve reuses KV via the worker). Full design + evidence:sglang-lmcache-mp-mode.md.
The (sglang, SGLangHiCache) pair configures the selected SGLang engine Pods
directly. It does not create a cache-server Deployment, Service, HPA, or
endpoint. The first implementation intentionally publishes no Ready
condition: Kubernetes Pod readiness proves that SGLang is serving, but does not
prove a HiCache host-tier write/read round trip. A dedicated readiness contract
is a separate follow-up.
The required integration shape is:
spec:
runtime: SGLang
type: SGLangHiCache
engineSelector:
matchLabels:
app: sglang
hiCache:
# Exactly one:
ratio: "2.0"
# sizeGB: 64
# Optional typed pass-through:
writePolicy: write_through
ioBackend: kernel
memoryLayout: layer_firstratio is a string containing a finite number greater than zero; sizeGB is a
positive integer. The optional fields are injected only when present, leaving
defaults to the SGLang version in the engine image. Their accepted values match
the SGLang CLI:
writePolicy:write_back,write_through,write_through_selectiveioBackend:direct,kernel,kernel_ascendmemoryLayout:layer_first,page_first,page_first_direct,page_first_kv_split,page_head
The webhook injects --enable-hierarchical-cache plus the corresponding
--hicache-* flags at Pod CREATE time. A one-container Pod may use any
container name; a multi-container Pod must name its engine container sglang.
Existing matching arguments are left byte-for-byte unchanged. A different
value, the opposite capacity mode, a malformed/duplicate argument, or existing
SGLang LMCache flags causes the whole injection to fail open without partial
HiCache wiring.
Changing or deleting the CacheBackend does not mutate live Pods. Roll the SGLang workload to apply a new configuration or switch between LMCache and HiCache. The webhook does not inspect image tags: the chosen image must support these SGLang arguments.
HiCache host memory is charged to the engine container's cgroup. The operator
must size the engine's memory request/limit and node capacity accordingly.
Inference-cache does not derive resource changes from sizeGB or ratio, and
does not add /dev/shm, hugepages, memlock, hostIPC, or privileged settings.
The KV-event subscriber reads its model identity from
spec.observation.modelID, independently of the HiCache configuration.
spec.integration.mode selects which cache tiers an engine is wired for. The default, Offload, is full participation: cache-aware routing (tier-1) PLUS KV offload (tier-2). Server-backed managed adapters include a controller-provisioned backend server; engine-local adapters such as SGLang HiCache provide tier-2 inside the engine pod and create no server workload or endpoint. EventsOnly wires the routing tier only.
What events-only does and does not provision. An events-only backend is the lighter, routing-only deployment:
- No provisioned server. The reconciler creates no Deployment and no Service for an events-only backend, and
status.endpointstays empty (there is no server address to publish). Flipping an existingOffloadbackend toEventsOnlysheds the previously-provisioned Deployment + Service on the next reconcile. - No KV connector. The pod webhook does NOT inject the
--kv-transfer-configarg or theLMCACHE_*env into the engine container — the engine container is left otherwise untouched. Because nothing dials a cache server, no endpoint is required, and the webhook injects an events-only engine pod even thoughstatus.endpointis empty (the usual empty-endpoint fail-open is bypassed for this mode). - Mode wins over host-tier configuration. If
spec.lmCacheis present,EventsOnlystill injects no LMCache connector or host-tier settings; the block is ignored for engine wiring. Operators should omitspec.lmCacheon routing-only resources so the manifest does not imply an active host tier.spec.remoteStorageis rejected rather than ignored because it declares a provider that nothing would dial. - The kvevent-subscriber sidecar is injected — when wired. That is the whole point of routing: once the sidecar is appended,
LookupRouteand the per-backendstatus.indexParticipationslice behave identically to a managed backend; only the offload tier (server + connector) is absent. The append is gated exactly as for a managed backend and is skipped fail-open when either gate is unmet: the controller must run with--kvevent-subscriber-imageset (unset by default, so a default install injects no subscriber) ANDspec.observation.modelIDmust be present to supply--model-id. When skipped, the webhook leaves the engine pod untouched and stamps noinjected-byannotation. - Evictions are tier-aware. The subscriber tags each prefix with a cache tier from the block lifecycle:
BlockStored→ T1 (resident in HBM). On aBlockRemoved, the two modes diverge. InOffloadmode the paired LMCache L2 tier still holds the block after the engine evicts it from HBM, so the subscriber (--ignore-block-removed=true) re-reports the evicted prefix at tier T2 (reload-able from host RAM), anchored at the eviction timestamp — the entry is kept, not dropped, and honestly tagged colder than HBM; a laterBlockStoredof the same content re-reports it back at T1. InEventsOnlymode there is no L2 retaining the block, so aBlockRemovedgenuinely means the prefix is gone and the hint MUST be pruned — the subscriber omits the flag and forwards the eviction asPREFIX_EVICTED. Either way a stale/mis-tagged hint is soft state (a cache miss at worst, never a wrong answer). Seedocs/design/kvevent-subscriber-wiring.md"L2 cache tier semantics".
Readiness is gated on the first KV event, same as managed. An events-only backend has no workload to wait on, so it is "up" the moment it exists — the firstEventTimeout clock starts immediately (status.firstAvailableAt is latched on the first reconcile). It then runs the same KV-event readiness gate as a managed backend: Ready=False/AwaitingFirstKVEvent until the first event, Ready=True/KVEventsObserved once status.indexParticipation.lastEventAt is observed, and Ready=False/NoKVEventsObserved, Degraded=True if the window elapses with no event. The base Ready reason is EventsOnlyActive. The managed-only advisory conditions FunctionalProbeOK, EngineKernelsHealthy, T2Degraded, and EngineCompatibility are never published on an events-only backend — there is no server to functionally probe, no LMCache native-kernel check (events-only loads no connector, so the lmcache-kernel-check init container is never injected), no tier-2 to mark degraded, and no injected KV connector that could be incompatible (events-only injects none, and an Offload→EventsOnly flip clears any prior verdict).
Why it exists. EventsOnly is the supported integration for hybrid-attention models that cannot take a vLLM KV connector — Qwen3.6/Next gated-DeltaNet, Mamba/Jamba, KDA, Falcon-H, Granite-hybrid, and similar. vLLM disables its hybrid KV-cache manager the moment any KV connector is loaded (KV-spec unification then fails at init), so these models cannot take the tier-2 connector; but their KV events coexist fine with the hybrid manager, so cache-aware routing still works. It is also simply a lighter deployment for routing-only users who do not want an offload tier at all.
Admission constraints. Because an events-only backend provisions no server, server-shaped configuration is structurally meaningless and is rejected at admission:
spec.remoteStorageis forbidden — any Managed or External declaration requests an offload provider that events-only deliberately does not wire.spec.autoscalingis forbidden — there is no workload to scale. The rejection is field-scoped tospec.autoscaling.
The standalone lmcache-server image
(spec.remoteStorage.lmCacheServer.image, default
lmcache/standalone:v0.4.7) and the lmcache client compiled into the engine
image (operator-supplied, or pip-installed into the engine at runtime)
communicate over a versioned wire protocol. They must be wire-compatible. A
mismatch does not fail loudly: remote KV stores fail (e.g. [Errno 32] Broken pipe / connection resets), the backend records 0 reload hits, and tier-2
(remote KV offload) is silently disabled with no surfaced error. The cache
plane keeps serving and routing; it simply never gets a tier-2 hit, which is
hard to distinguish from a cold cache.
The same silent store-failure signature can also come from an under-provisioned server that is OOMKilled under load — the standalone server keeps KV in memory, and a default memory request far below a large model's working-set KV (e.g. a 32B model's KV is tens of GB) will OOM the server the moment stores begin, dropping every connection. Size the server's memory to the expected working set. (Surfacing tier-2 store-failure / hit-rate health so neither failure mode stays silent is a separate follow-up.)
Because of this:
- The default
serverImageis pinned to a specific, non-floating version, never:latest. A floating tag can drift to a server build whose wire protocol no longer matches the client, reintroducing the silent-disable failure mode on an unrelated pull. (The default tagv0.4.7is version-aligned with the validated lmcache 0.4.7 client, but the standalone server image was not independently wire-tested; confirm against a tested build — ideally an@sha256:digest — before release. See theTODOondefaultLMCacheServerImageininternal/adapters/builtin/storage/lmcache_server.go.) - Pin both sides. When an operator overrides
remoteStorage.lmCacheServer.image, they must choose an lmcache-server version that is wire-compatible with the lmcache client version their engine image carries, and pin the engine's client too (apip install lmcacheat engine startup is itself a floating reference). For non-local runs, prefer an@sha256:digest. - IC cannot auto-match these versions: it has no source of truth for the engine's client version (the engine image is operator-supplied and the client may be pip-installed at runtime), so it cannot detect or warn on a skew today. The mitigation is this alignment contract plus the pinned default; runtime detection / a tier-2 health signal is a separate follow-up.
The lmcache client compiled into the engine image ships native CUDA
kernels (lmcache.c_ops). They must match the engine image's CUDA runtime. A
mismatch — e.g. a pip install lmcache that pulls a CUDA-13-built wheel onto a
CUDA-12.9 image — fails to load (Failed to import backend lmcache.c_ops: libcudart.so.13) and lmcache silently falls back to a single-stream torch
path that does not parallelize. T2 reload still works in isolation but
serializes under concurrency (measured ~10× slower), and the failure surfaces
only as a log WARNING. This is the same silent-degradation class as the
wire-protocol skew above, in its local-kernel variant.
Because import lmcache.c_ops is overridden to a fallback shim on load failure
(so it always succeeds and cannot be used as a health check), the control plane
detects this at deploy time with an injected lmcache-kernel-check init
container that force-loads the native extension from disk in the engine's own
image. It reports onto the CacheBackend EngineKernelsHealthy condition (see
Conditions) and is configured per-CacheBackend via the
inferencecache.io/lmcache-kernel-check annotation:
| Annotation value | Behavior |
|---|---|
auto (default / unset) |
Inject in report-only mode only when the engine container requests a GPU (the kernels are GPU-only; a CPU build legitimately has none). |
report-only |
Always inject; a c_ops load failure makes the detector exit 0, so it does not block the engine pod (best-effort fail-open — see the residual cases in Boundaries). The condition surfaces the result. |
strict |
Always inject; on failure the engine pod stays in Init and never serves (fail-closed), and the managed CacheBackend Ready is downgraded with reason EngineKernelDegraded. |
off |
Never inject. |
The annotation value is validated at admission — an unrecognized value (e.g. a
strcit typo) is rejected, so a typo cannot silently relax strict
enforcement back to report-only. Changing the annotation affects only
newly-admitted engine pods; the EngineKernelsHealthy condition and any
strict Ready downgrade reflect each pod's actual admitted mode (read from
the pod, not the CacheBackend's current annotation), so flipping the annotation
on a live backend takes effect as its pods roll.
Engine scope — vLLM only today. The kernel-check init container is provided
by the runtime adapter via the private internal InitContainerProvider capability, which
only the vLLM+LMCache adapter implements. The SGLang+LMCache adapter does not
implement it yet, so inferencecache.io/lmcache-kernel-check has no effect on
SGLang engine pods and EngineKernelsHealthy is not published for them — even
though SGLang loads the same lmcache client and would benefit from the same
check. The annotation is still shape-validated for any CacheBackend (the value
rule is engine-agnostic); it simply injects nothing on the SGLang path.
Extending the check to SGLang is a follow-up.
Boundaries (what the check does and does not prove):
EngineKernelsHealthy=Truemeans the native kernels loaded — it does not mean T2 reload is fast under concurrency. The reload-serialization symptom is validated separately by a real-GPU concurrency canary.- The check proves load-time linkage (it catches a missing/mismatched
libcudart). It does not prove runtime executability: alibcudartpresent but paired with a too-old driver loads cleanly and fails only at kernel launch. That residual is caught only at runtime. - Strict-mode GPU cost: a pod stuck in
Init(failing the check in strict mode) still holds itsnvidia.com/gpureservation while serving nothing. Reclaim it by fixing the engine image's lmcache/CUDA alignment or switching the annotation toreport-only. - The check runs
import torch(the native extension links libtorch), adding a few seconds to GPU engine-pod startup. The engine imports torch anyway. - Report-only fail-open is best-effort. The init container runs the engine
image's own
python3; in report-only mode the detector always exits 0, so ac_opsfailure never blocks the pod. The init container declares small CPU/ memory requests and no limits — the most broadly-compatible shape, but note that no resource shape is fail-open under every namespace policy: aResourceQuota/LimitRangethat requires per-container requests rejects a container with none, while aLimitRangeper-container max can reject large ones. Small requests stay below any max the GBs-needing engine already satisfies and are subsumed by the engine's in the pod's effective request (so no scheduling/quota footprint increase); omitting limits avoids the max-limit trip. The other residual ways it could block arepython3failing to start at all (which means the Python engine is itself broken — not a false outage caused by this check) or an OOM duringimport torch(the check sets no memory limit, so the import is bounded by the pod/node unless a namespaceLimitRangedefaults a memory limit onto it — in which case a too-small default could OOM the import, the same way it would constrain any unlimited container). The check is deliberately not wrapped in a shell to force exit 0, because a minimal/distroless image could lack/bin/shand reintroduce the very block the wrapper aimed to avoid.
EngineKernelsHealthy complements FunctionalProbeOK (which round-trips the
server-side cache path): the kernel check catches the engine-side load cause
that the round-trip probe cannot see.
spec.remoteStorage.mooncake selects the Mooncake provider adapter
(internal/adapters/builtin/storage/mooncake.go) to reconcile the standalone
Mooncake master workload. The vLLM runtime adapter
separately wires engine pods to it through the LMCache remote-binding contract. Mooncake is
the durable / shared cache path — the backend-type expression of the persistence
decision in
docs/design/lmcache-server-persistence.md
(the in-memory lm:// lmcache-server is the simple default; Mooncake is the
scalable one — durability is a backend choice, not a generic volume knob).
Operator requirement — the Mooncake master runs on the host network. Unlike LMCache's
lm://(one server, one port, one connection — a virtual ClusterIP suffices), Mooncake is a peer-to-peer transfer-engine mesh: the master on:50051returns only a directory pointer ("this block lives on node B"), and the engine then dials that node's real IP on a dynamically negotiated port to move the KV bytes. A ClusterIP Service forwards only the ports declared on it, and CNI overlay pod IPs are not reachable for the mesh — so the adapter renders the master withhostNetwork: truebehind a headless Service (clusterIP: None), whose DNS name (published asstatus.endpoint) therefore resolves straight to the master's node IP with every port reachable. Consequences you must plan for:
The namespace must permit
hostNetwork— a Pod Securityrestrictednamespace will reject the master pod.The master reserves its ports (50051 / 8080 / 9003) on its node (the API server defaults
hostPort=containerPortfor hostNetwork pods), and its Deployment uses theRecreaterollout strategy — a rolling surge would collide on those ports.The master is a singleton.
spec.replicas > 1andspec.autoscalingare rejected at admission whenremoteStorage.provider: Mooncake: a second replica either fails to schedule because its node ports are already bound or comes up as an independent master and silently splits the store.spec.replicas: 0(disabled) and1remain valid.Network exposure — plan for it. Host networking publishes the master's RPC (
50051), metadata (8080) and metrics (9003) ports, plus the transfer engine's dynamically negotiated data ports, directly on the node's interfaces, outside the pod network.NetworkPolicyselects pods by pod IP and therefore does not constrain a hostNetwork pod's listeners — the isolation you get from pod-network policy is simply absent here. Restrict access with node-level controls instead: security-group / firewall rules on the node interfaces, and by constraining which nodes the master and its engines may schedule onto. Treat all of these ports as cluster-internal only; none of them authenticate callers.Engine pods need host networking too — opt in with
spec.integration.engineHostNetwork: true. Mooncake's mesh is dialed from the engine, so an overlay engine pod cannot participate. With the flag set, the Pod webhook moves matched engine pods onto the host network (hostNetwork+dnsPolicy: ClusterFirstWithHostNet) alongside the usualLMCACHE_*wiring. Until it is set, admission warns on every MooncakeremoteStorageapply and the backend reportsReadywhile transferring zero KV.It is opt-in, never injected by default, because it rewrites the networking of a pod you own.
hostNetworkis a privilege, and mutating webhooks run before Pod Security validation — so silently adding it would turn a working engine pod into one arestrictednamespace rejects, with an error naming Pod Security rather than this controller. The flag is rejected on backend types that do not need it, so it can never sit inert.Setting the flag does not move pods that already exist. Injection happens at pod admission, and a Pod's
hostNetworkis immutable — so enabling it changes only pods admitted afterwards. Roll your engine workload (kubectl rollout restart deployment/<engine>) after enabling it, or the running engines stay on the overlay and keep transferring zero KV.Host networking is applied together with the
LMCACHE_*connector, behind the same gate: if the backend has not yet publishedstatus.endpoint, a matched engine pod admits un-wired — no connector and nohostNetwork. It is never granted to a pod that has nothing to use it for. Such a pod needs a roll once the backend reportsReadyin any case, since it is missing the connector env too.Engine scheduling and rollout, under host networking. These constraints land on your engine Deployment, which this controller does not own and therefore cannot clamp the way it clamps the master:
- The API server defaults
hostPorttocontainerPortfor hostNetwork pods, so each engine replica reserves its serving port (e.g.8000) on its node — at most one engine replica per node per port. Size the engine's replica count against schedulable nodes, not just GPUs.- A
RollingUpdateengine Deployment can deadlock: the surge pod cannot bind a port the outgoing pod still holds, so it staysPendingforever and the rollout never completes. Usestrategy: Recreate(ormaxSurge: 0) on a hostNetwork engine Deployment.- Pod-network isolation is absent for engine pods too — the same
NetworkPolicycaveat above applies to them.This is inherent to Mooncake, not a choice the adapter can avoid. Host-only LMCache and the standalone LMCacheServer provider are unaffected and stay on the pod network.
Mooncake is wired as an LMCache remote backend, not vLLM's native MooncakeStoreConnector. The engine runs the same LMCache connector the LMCache backend uses (kv_connector=LMCacheConnectorV1) pointed at a mooncakestore://host:port remote store — the Mooncake analog of lm://. So the engine-side injected wire follows the same pod-webhook engine-wiring contract except that LMCACHE_REMOTE_URL carries the mooncakestore:// scheme. The native MooncakeStoreConnector is configured exclusively through a MOONCAKE_CONFIG_PATH JSON file (it has no env-var surface for the master address), and the pod-mutating webhook can only inject env + args — it cannot write a file into a user-owned engine container — so routing the controller-resolved master endpoint through LMCACHE_REMOTE_URL=mooncakestore://… is the only path that lets status.endpoint reach the engine via injection alone. Operators who prefer the native connector pre-bake their own config file; this adapter targets the auto-wired path.
Provider-side fields consumed by provider.ResolveMooncakeServer:
| Field | Default | Purpose |
|---|---|---|
remoteStorage.mooncake.image |
docker.io/kvcacheai/mooncake:0.3.11.post1 (pinned, non-floating; fully qualified) |
Container image for the standalone Mooncake master. Fully qualified (docker.io/…) so CRI-O nodes without short-name resolution configured do not reject it; it is version-aligned with the mooncake-transfer-engine 0.3.11.post1 release on PyPI. Pin to an @sha256: digest for non-local runs. |
remoteStorage.mooncake.command |
mooncake_master --rpc_port=50051 --metrics_port=9003 --enable_http_metadata_server=true --http_metadata_server_host=0.0.0.0 --http_metadata_server_port=8080 |
Master command and arguments. The default launches RPC, Prometheus metrics, and the embedded HTTP metadata server. Do not change the RPC (50051) or HTTP metadata (8080) ports through this override: the rendered Service, readiness probe, status endpoint, and engine binding use those fixed values and are not derived from free-form command text. |
remoteStorage.mooncake.resources |
memory request 4Gi, limit 8Gi |
Resources for the managed master container. An explicit typed block replaces the defaults; autoscaling also supplies a 250m CPU request when no positive CPU request is present. |
The Service exposes the master's RPC port (50051) first so the reconciler's engine-agnostic serviceEndpoint helper publishes it into status.endpoint, plus the HTTP metadata port (8080).
Engine-side: the adapter injects the same --kv-transfer-config '{"kv_connector":"LMCacheConnectorV1","kv_role":"<role>"}' arg and the same LMCACHE_* / VLLM_USE_V1 / INFERENCECACHE_FAIL_OPEN / PYTHONHASHSEED env as an LMCacheServer binding, with LMCACHE_REMOTE_URL=mooncakestore://<status.endpoint>. Chunk size, serializer, and host-memory settings come from spec.lmCache. The reserved args/env are therefore identical. The kvevent-subscriber sidecar is also identical (the KV-event stream comes from vLLM, not the L2 store; --hash-scheme=vllm, --ignore-block-removed=true).
Transfer-engine tuning is operator-supplied, not env-injected. Mooncake's static transfer-engine config (metadata_server, protocol tcp/rdma, device_name, segment sizes) lives in LMCache's extra_config, which is read from an engine-side config file (LMCACHE_CONFIG_FILE / MOONCAKE_CONFIG_PATH) — not from env vars, so the webhook cannot inject it. The adapter wires the controller-resolved master address + the connector; the transfer-engine defaults (P2P-handshake metadata) cover the simplest deployment, and operators provide a config file for a real RDMA / HTTP-metadata setup. A kind reference stack that validates the end-to-end Mooncake deployment shape (the A2-equivalent of the LMCache reference stack) is a tracked follow-up. The master image entrypoint + RPC/metadata/metrics ports are now confirmed on a live cluster; until that stack lands, treat the extra_config transfer-engine defaults and the full end-to-end deployment shape as not-yet-cluster-validated.
| Field | Type | Purpose |
|---|---|---|
endpoint |
string | Observed endpoint clients should use. For External ownership this mirrors spec.remoteStorage.endpoint; for Managed remote storage it is populated from the controller-rendered Service. It stays empty for host-only and events-only backends because neither has a remote provider address to publish. |
matchedEnginePods |
integer | Snapshot count, at the last reconcile, of pods in the CacheBackend's namespace whose labels satisfy spec.engineSelector. Pointer in Go so nil ("not yet computed") is distinguishable from an observed 0 ("computed and zero pods match"). Refreshed at reconcile cadence — not a real-time per-pod counter. The steady cadence is 30s; during known churn the reconciler uses a conditional 5s cadence when the observed matching Pod count differs from the desired replica sum of Deployments whose pod-template labels match the selector. This keeps the no-Pod-watch design while reducing stale operator output during rolling restarts. The field stays nil when no claim-capable selector is configured — both when spec.engineSelector is absent AND when spec.engineSelector.matchLabels is present but empty (the webhook treats an empty match map as no-claim by design, so the count is no-claim too). A CR that previously had a non-empty selector and just lost it gets its prior value cleared back to nil so the printer column does not advertise a stale match. |
engineSelectorMessage |
string | Operator-facing diagnosis for selector drift. Set when spec.engineSelector.matchLabels is configured and matchedEnginePods is observed as 0 while engine pods are expected; the message echoes the selector (spec.engineSelector.matchLabels={...}) and states that no Pods in the namespace match. If the selector matches a Deployment that is intentionally scaled to zero, matchedEnginePods still reports the observed 0, but this message stays empty because no engine pods are expected. Cleared once at least one pod matches, the matching Deployment is scaled to zero, or the selector is removed. The controller also emits a Normal EngineSelectorUnmatched Event when the initial observation is zero, when a previously non-zero match count transitions to zero, or when upgrading an existing zero-count status that did not yet have the diagnostic message; steady-state zero with an unchanged message does not re-emit. |
failOpen |
boolean | Observed echo of the effective spec.integration.failOpen. Represented as a pointer in Go so an explicit false is serialized and operators can read the current mode from status alone. |
indexParticipation |
object | Per-backend slice of the cluster-wide cache index, projected from the server's /snapshot by grouping replicas by owning CacheBackend. Populated by the CacheIndex poller (status-only). Object is unset until the poller has observed a successful scrape that names the backend's replicas (see Index Participation). |
firstKVEventObservedAt |
time | Write-once latch: the first time the KV-event readiness gate observed indexParticipation.lastEventAt populated. This is the durable "have we EVER seen a KV event" signal — lastEventAt itself is a current-view projection the poller legitimately clears when a backend's replicas drain, so reading it alone would let a backend that already passed the gate regress. Set write-once by the controller and never cleared (a monotonic marker). It is inert while the backend is not managed (External / unsupported runtime) and is intentionally left in place there — clearing it would be ineffective anyway, since the preserved poller-owned lastEventAt would immediately re-satisfy the gate on a return to the managed path — so a return to managed stays Ready without re-gating, consistent with the "ever observed" contract. |
firstAvailableAt |
time | Write-once latch: the stable anchor for the firstEventTimeout clock. For a managed (Offload) backend it latches the first time the managed cache-backend workload was observed Available — used instead of the live Deployment Available condition's LastTransitionTime precisely because that resets on an availability flap. For an EventsOnly backend there is no workload to wait on, so it latches at the first reconcile (the clock starts immediately). Anchoring on this monotonic value keeps the elapsed window growing WITHIN a serving mode, so once a backend breaches the timeout (Degraded/NoKVEventsObserved) a later flap cannot bounce it back to AwaitingFirstKVEvent — it stays Degraded until an event arrives. It is stable across flaps and a recreated managed Deployment, but NOT across a mode change: a server-bearing→EventsOnly flip re-anchors it to the flip moment (and bypasses the sticky NoKVEventsObserved reason) so the flip gets a fresh first-event window rather than inheriting the old mode's availability time or timed-out verdict, and an unmanaged transition clears it so a later re-entry starts fresh. |
observedServerInstance |
string | The controller's cascade-decision baseline — a stable identifier for the Ready cache-server pod set the controller last anchored against. NOT a live "current pod set" view: it is intentionally pinned through transient rolling-update midpoints and through no-Ready windows so the cascade does not fire on rollbacks or transient outages. For the current matched-pod inventory operators should consult status.matchedEnginePods (engine side) and kubectl get pod (cache-server side). Shape: <pod-uid>:<restart-sum> per Ready pod, comma-joined and lex-sorted by pod name; <restart-sum> sums pod.status.containerStatuses[].RestartCount filtered to the cache-server's own containers (the container names declared on the owned Deployment's pod template). Sidecars injected by other admission webhooks (service-mesh proxies, Datadog, etc.) appear in containerStatuses but are absent from the template and are intentionally excluded — a sidecar crash-loop must not advance the identifier and roll the engine fleet. An in-place restart of a cache-server container (kubelet respawning a crashed container — OOM with restartPolicy=Always reuses pod.UID) DOES advance the identifier and is observable. On a transition that reflects an actual replacement (a prior pod is gone, or a persisting pod's restart-sum advanced — NOT a rolling-update strict-superset midpoint) the reconciler cascade-restarts every engine Deployment that owns pods carrying this backend's inferencecache.io/injected-by + matching injected-by-uid, by patching inferencecache.io/cache-server-restart-trigger onto each Deployment's pod template — the same mechanism kubectl rollout restart uses. Rate-limited to once per ~30s per backend. Empty until the first Ready pod; empty→set never cascades (there is no prior server-instance to invalidate, so any engines that connected during the empty window are connecting to the very pod now being baselined). Strict-superset transitions are persisted as the new baseline ONLY when the owning Deployment is converged (spec.replicas == status.readyReplicas == status.updatedReplicas == len(live Ready pods) AND observedGeneration >= metadata.generation — the live-count clause cross-checks the Deployment's reported state against the pod list this reconcile actually saw, so a stale status.readyReplicas=1 while two pods are mid-rollout cannot fake convergence) — a converged steady-state widening is an operator-driven scale-up, so the added pods must enter the baseline or a later replacement of just an added pod would still look like a strict superset and miss the cascade. Strict-superset transitions where the Deployment is NOT converged are rolling-update midpoints; persisting them would let a rolled-back rollout (new pod briefly Ready, then killed, leaving the original pod alone) look like "the new pod was replaced" and false-cascade. Stale-while-unavailable: when no Ready cache-server pod exists at all (Deployment scaled to 0, mid-rollout, image-pull stuck), this field intentionally retains its prior value rather than clearing — clearing would turn the eventual recovery's "" → new-uid:0 transition back into a first-observation baseline and silently skip the cascade, defeating the controller's purpose. Inert and cleared on every transition out of the managed-provider path: External ownership, host-only caching, events-only mode, and unsupported runtimes. The in-process cascade shadow is wiped alongside the field on each of these paths so a later return to a managed provider starts from a clean baseline rather than the prior-period UID. Operator-side recovery for the upstream LMCache LMServerConnector EPIPE-on-restart bug — see LMCache/LMCache#3565. |
observedGeneration |
integer | The .metadata.generation last reconciled by the controller. Lets clients tell whether the observed status reflects the current spec. |
conditions |
array | Kubernetes conditions keyed by type. See Conditions. |
The set of published condition types depends on the backend's integration mode and type:
- Offload-managed backends (
spec.integration.mode=Offloadon a managed type, where the controller renders a Deployment + Service) publish up to seven:Ready,Degraded,Progressing,FunctionalProbeOK,EngineKernelsHealthy(when a matched engine pod runs the lmcache kernel-check),T2Degraded(once a tier-2/LMCache backend has been exercised), andEngineCompatibility(when an injected engine pod is observed crash-looping after connector injection). - Host-only backends (resources with no
spec.remoteStorage) publishReady,Degraded, andProgressing, plus the engine-side advisory conditions when applicable. Their endpoint stays empty.HostOnlyActiveis the baseReady=Truereason before the KV-event gate overlaysAwaitingFirstKVEvent,KVEventsObserved, orNoKVEventsObserved. - Events-only backends (
spec.integration.mode=EventsOnly) publish exactly three:Ready,Degraded,Progressing.FunctionalProbeOK,T2Degraded,EngineKernelsHealthy, andEngineCompatibilityare Offload-managed-only and are never published on an events-only backend — there is no provisioned server to functionally probe, no tier-2 offload to mark degraded, no LMCache native kernels to check, and no injected KV connector that could be incompatible (events-only injects none); an Offload→EventsOnly flip clears all four (see Events-only mode). - Externally owned remote storage publishes
Ready+Progressingonly (there is no rollout to degrade and no probe to drive; the operator manages the provider out-of-band and the controller only validates and mirrors the endpoint).
The Ready / Degraded / Progressing semantics below apply to both Offload-managed and events-only backends (an events-only backend has no workload to roll out, so it is "up" the moment it exists and the KV-event gate starts immediately — see Events-only mode); the FunctionalProbeOK, T2Degraded, EngineKernelsHealthy, and EngineCompatibility rows are Offload-managed-only.
Managed backends (Offload-managed; the FunctionalProbeOK / T2Degraded / EngineKernelsHealthy / EngineCompatibility rows do not apply to events-only):
| Type | Meaning |
|---|---|
Ready |
True once the backend Deployment has rolled out its current generation, has enough updated + available replicas to serve traffic, and — when the KV-event readiness gate applies — at least one KV event has been observed for the backend (reason KVEventsObserved), and — when the functional-probe gate applies — the most recent probe call succeeded across every stage the backend runs. Workload Available but no event yet is Ready=False, reason AwaitingFirstKVEvent. Workload Available and KV-event observed but the probe reported a stage failure is Ready=False with reason ProbeIngestFailed / ProbeRoutingFailed / ProbeT2Failed. Deployment-level reasons: BackendReady (both gates disabled and Available), RolloutInProgress, ScaledToZero, ReplicasUnavailable. And — when a matched engine pod admitted in strict kernel-check mode reports a kernel load failure — Ready=False with reason EngineKernelDegraded (see EngineKernelsHealthy); report-only mode never downgrades Ready. The BackendDegraded / BackendRecovered Events narrate the ReplicasUnavailable → BackendReady / KVEventsObserved transitions. |
Degraded |
True when the backend is in a terminal unhealthy state: rolled out but replicas unavailable (reason ReplicasUnavailable), or the managed workload is Available but no KV event observed within firstEventTimeout (reason NoKVEventsObserved). False (NotDegraded) otherwise. The functional-probe gate does NOT participate in Degraded — a probe failure is reflected only in Ready and FunctionalProbeOK, leaving Degraded reserved for managed-Deployment health (so an operator can tell "the probe says the cache plane is broken" apart from "the workload itself is in a terminal state"). |
Progressing |
True while the controller is still driving the live state toward the desired state (rollout in flight, first apply, awaiting first KV event). False once converged (Synced), stuck (Degraded), or scaled to zero (ScaledToZero). The pair (Ready=False, Progressing=True) means "still converging"; (Ready=False, Progressing=False) means "stuck/degraded" (or scaled to zero). |
T2Degraded |
Advisory tier-2 (external offload, e.g. LMCache) health, derived from status.indexParticipation.t2HitRate (written by the CacheIndex poller). Published only once the tier has been exercised (external lookups observed): True/T2ZeroHitRate when it was queried but served zero reloads (wired but useless — a store/connection failure, an under-sized remote server, or a scheduler/worker hash mismatch); False/T2Serving when it is serving reloads (hit-rate > 0). Absent entirely until the tier is exercised (distinct from False). It never gates Ready — tier-2 is an optimization, not a serving dependency (fail-open). For Prometheus alerting use the inferencecache_backend_t2_hit_rate{backend} gauge (CR .status is not scraped). The signal is lifetime-cumulative — it flags a tier that has never served a reload (the silent-from-start failures: scheduler/worker hash mismatch, server OOM, version skew); a mid-life regression (served reloads before, now zero) keeps hit-rate > 0 and so does not trip T2Degraded. That windowed case is caught instead by the per-pod LMCacheT2NoHits alert. |
FunctionalProbeOK |
The most recent functional-probe outcome. True/ProbeOK when every enabled stage (ingest, routing, and — for LMCache — tier-2 put/get) round-tripped; True/ProbeBypassed when the operator opted this CR out via the inferencecache.io/skip-functional-probe: "true" annotation; False/ProbeIngestFailed, False/ProbeRoutingFailed, or False/ProbeT2Failed when the named stage failed, with the server's diagnostic in .message; Unknown/ProbeError when the controller could not reach the server's /probe endpoint at all (transport error, 5xx) AND no prior stage failure existed. Sticky-False: an HTTP error while a False/Probe*Failed is already published preserves the prior failure and keeps Ready downgraded, so a transient server outage cannot fade a known per-stage failure back to Unknown and then to Ready=True. See functional-probe gate. |
EngineKernelsHealthy |
Engine-side native CUDA-kernel (lmcache c_ops) load health, read from the lmcache-kernel-check init container on matched engine pods. True/KernelsHealthy when the native kernels loaded on every reporting pod; False/KernelLoadFailed when one or more failed to load — a libcudart/CUDA-runtime mismatch (the root cause), a CPU/pure-python build with no compiled extension, or lmcache not importable; the specific cause is in the condition .message. In strict mode a False also downgrades Ready (reason EngineKernelDegraded); Unknown/KernelCheckError when a check terminated without a recognized result; Unknown/KernelCheckPending while a check is still running. Absent when no matched engine pod runs the check (CPU backends, annotation off). Default mode is fail-open observability — it does NOT gate Ready unless strict. See client kernels ↔ image CUDA alignment. |
EngineCompatibility |
Advisory engine↔connector observation, derived from the live container state of the engine pods this backend injected cache config into. Published False/InjectedEngineCrashLooping only when an injected engine container is in CrashLoopBackOff after the cache plane wired a KV connector — the live observation, not a confirmed root cause. A structural connector incompatibility is a common cause, canonically a hybrid-attention model (Qwen3.6/Next gated-DeltaNet, Mamba/Jamba, Falcon-H, Granite-hybrid, …): vLLM disables its hybrid KV-cache manager the moment any KV connector (LMCache, Mooncake, NIXL) is wired, then fails KV-spec unification at init. But a crash-loop is generic — it can equally be a bad image, command, missing dependency/secret, or OOM — so verify the cause via the engine logs. Absent when no injected engine is stuck. It never gates Ready (the engine is operator-owned; Ready is driven by the managed Deployment + the KV-event gate) — it names an otherwise-silent crash-loop that often sits behind a NoKVEventsObserved Degraded and points at the likely fix. If it is the connector (e.g. a hybrid model), the routing-preserving fix is the connector-less events-only integration — set spec.integration.mode: EventsOnly (kv-events, no offload), the supported remedy for hybrid-attention models. (Do not reach for inferencecache.io/skip-inject: it opts the pod out of cache wiring entirely, the kvevent-subscriber included, so it stops routing rather than preserving it.) An InjectedEngineCrashLooping Warning Event narrates the transition. See supported-model matrix. |
When the desired replica count is owned by an HPA (spec.autoscaling set) the controller compares the Ready condition against the HPA-written Deployment spec.replicas rather than the user-set spec.replicas.
Externally owned remote storage:
Resources express this shape with
spec.remoteStorage.ownership: External, the selected provider, and
spec.remoteStorage.endpoint. There is no Deployment to roll out, so provider-specific endpoint validation
is the only readiness signal the controller has. The controller mirrors the
trimmed endpoint to status.endpoint and publishes both conditions immediately
on every reconcile (the KV-event gate never applies to external ownership):
| Type | Status | Reason | Meaning |
|---|---|---|---|
Ready |
True |
ExternalEndpointAccepted |
The active endpoint field is non-empty and valid for the selected provider. LMCacheServer accepts host:port or lm://host:port; Mooncake accepts host:port or mooncakestore://host:port; Redis accepts bare host:port. A numeric port in 1-65535 is always required, embedded whitespace and URL path/query/fragment components are rejected, and IPv6 must be bracketed. The controller provisions no provider pod for External ownership, so admission acceptance is the readiness signal. |
Ready |
False |
ExternalEndpointMissing |
The active endpoint field is empty or whitespace-only. Current admission rejects this, so the state is reachable only for a CR already stored before the webhook was installed. Status reflects the gap loudly rather than dropping the condition. |
Ready |
False |
ExternalEndpointInvalid |
The active endpoint is non-empty but fails the selected provider's shape check. Current admission rejects these values; the reason is reachable only for a CR stored before the relevant rule shipped. The message names spec.remoteStorage.endpoint and carries the shape error. The pod webhook applies the same validation and admits the engine pod unwired on failure. |
Progressing |
False |
mirrors Ready's reason | External ownership completes admission immediately — there is no rollout the controller is still driving. Always False; the reason matches Ready (ExternalEndpointAccepted / ExternalEndpointMissing / ExternalEndpointInvalid) so kubectl describe shows a coherent pair. |
Reachability of an externally owned endpoint is not probed by the controller;
trusting the operator is part of External ownership. A future enhancement could
degrade Ready on a probe failure, but that is deliberately out of scope today
(fail-soft, never a serving dependency).
kubectl get cachebackend displays a Ready column sourced from status.conditions[?(@.type=="Ready")].status (the standard K8s pattern — operators read readiness through conditions, not through a custom enum field), the observed status.endpoint, a Matched column sourced from status.matchedEnginePods, plus status.indexParticipation.prefixCount (as PREFIXES) and status.indexParticipation.lastEventAt (as LASTEVENT). Managed provider backends therefore show readiness, the endpoint, the operator-actionable engine-fleet count, and live index participation once reconciliation has populated them and the poller has observed a /snapshot tick. An empty Matched cell means the count has not yet been computed (e.g. cold start before the first reconcile) or the CR has no spec.engineSelector configured. Externally owned bindings display the operator-supplied endpoint immediately. Their indexParticipation is typically unset — the operator-managed provider itself has no observation sidecar — but it is not special-cased by ownership: the poller attributes replicas by engine-pod selector/annotation. An externally owned binding whose engine pods run the subscriber therefore projects indexParticipation the same way as a managed binding. The readiness gate still never applies to External ownership (readiness comes from endpoint acceptance), so a populated lastEventAt affects the displayed columns but not readiness.
The cache plane has two levers — routing (driven by kv-events; works on any engine) and the tier-2 offload (driven by a vLLM KV connector). Standard-attention models get both; hybrid-attention models get routing only, because vLLM's KV-connector interface is mutually exclusive with its hybrid KV-cache manager — wiring any connector triggers a fatal KV-spec-unification error at engine init. This is a vLLM-level constraint that applies to any connector (LMCache, Mooncake, NIXL); it is not an LMCache gap, and swapping the backend does not lift it.
| Model family | Attention | Routing (T1) | Offload (T2) | Integration |
|---|---|---|---|---|
| Llama, Qwen3 dense, Mixtral, … | standard | ✅ | ✅ | full (connector + subscriber) |
| Qwen3.6 / Qwen3-Next (gated-DeltaNet) | hybrid | ✅ (events-only) | ❌ | events-only — supported (spec.integration.mode: EventsOnly) |
| Mamba / Jamba, Falcon-H, Granite-hybrid, KDA | hybrid | ✅ (events-only) | ❌ | events-only — supported (as above) |
A hybrid model wired with the full (connector) integration crash-loops at engine init; the controller surfaces the crash-loop as EngineCompatibility=False/InjectedEngineCrashLooping (above) instead of leaving it a silent CrashLoopBackOff. The condition reports the observation, not a proven cause — confirm via the engine logs (a crash-loop can also be a bad image/command/secret/OOM). When it is the connector incompatibility, set spec.integration.mode: EventsOnly — the supported connector-less integration that preserves routing (kv-events, no offload; the controller provisions no server and injects no connector, but still wires the kvevent-subscriber). Avoid inferencecache.io/skip-inject, which opts the pod out of cache wiring entirely (subscriber included) and so drops routing rather than preserving it.
Ready (for managed backends) means "the managed backend is up and the cache plane is actually receiving engine state" — not merely "the workload rolled out". The reconciler observes the managed cache-backend Deployment it owns (its rollout + Available condition); that proves the backend workload is up but says nothing about whether engine pods are attached and their ZMQ KV-event publishers are publishing. An engine can be serving HTTP while its publisher is silent (mis-configured --kv-events-config, a ZMQ bind failure, or an in-process publisher crash), or no engine pods may be wired to the backend at all — in either case LookupRoute keeps returning NO_HINT for that backend's prefixes while the CR claims everything is fine. The gate makes that silent degradation loud.
The signal source is status.indexParticipation.lastEventAt (written by the CacheIndex poller from engine-pod reports); the reconciler only reads it. Once the managed cache-backend Deployment is Available:
- no event yet, within
firstEventTimeout→Ready=False/AwaitingFirstKVEvent,Degraded=False. The timeout clock starts when the backend becomes "up" — for a managed (Offload) backend when its Deployment first reportsAvailable, for anEventsOnlybackend (which has no workload) on the first reconcile it is wired — captured instatus.firstAvailableAt(a stable anchor, so a later availability flap does not restart the window; see that field's row for the mode-transition re-anchor). - at least one event ever observed →
Ready=True/KVEventsObserved,Degraded=False. An event already present on the first reconcile counts — there is no required transition throughAwaitingFirstKVEvent. "Ever observed" is durable: the first observation is latched intostatus.firstKVEventObservedAt, because the poller'slastEventAtis a current-view value it clears when the backend's replicas drain — without the latch a drained-but-healthy backend would wrongly fall back toAwaitingFirstKVEvent. The gate is a first-event startup probe, not an ongoing liveness check. - no event by
firstEventTimeout→Ready=False/NoKVEventsObserved,Degraded=True/NoKVEventsObserved. Once Degraded it stays Degraded until an event arrives, then transitions to Ready.
The gate is on by default and opt-out per CR with the annotation inferencecache.io/require-kv-events: "false" (alpha soft-rollout knob; an annotation rather than a spec field so it can be retired once the gate is trusted). External ownership is always exempt — the control plane does not own a provider workload to gate, so readiness is determined by accepting the provider-specific spec.remoteStorage.endpoint as described above.
Operator note. If a backend is stuck at Ready=False/AwaitingFirstKVEvent (and then flips to Degraded=True/NoKVEventsObserved after firstEventTimeout), either no engine pods are attached to the backend or the engine's KV-event publisher is mis-configured — check that engine pods are wired to the backend, then the engine's --kv-events-config and that its ZMQ socket bound. In kubectl get cachebackend the Ready column shows False and the LASTEVENT column shows <none>; the specific reason (AwaitingFirstKVEvent / NoKVEventsObserved) and the remediation hint live in the Ready / Degraded conditions, which kubectl describe surfaces along with the NoKVEventsObserved Warning Event.
The KV-event gate confirms that "at least one engine event has flowed into the index"; the functional-probe gate confirms that the cache plane's round-trip is actually working. Past phases have seen silent-failure modes — a tenant_id mismatch between subscriber and lookup that returns NO_HINT for every routed request; a proxy↔server hash-encoding skew that produces 0% PREFIX_MATCH despite well-formed events; a tier-2 client/server version skew that 0-hits across millions of queries — where each gate above said Ready=True while the cache plane was effectively a no-op. The functional probe drives a deterministic synthetic round-trip per CacheBackend and reflects the per-stage result on the CR.
Composition order on a managed CacheBackend's Reconcile is managed-readiness → KV-event gate → functional-probe gate. The probe gate only fires when the upstream KV-event gate would otherwise say Ready=True (a broken upstream can't be diagnosed by a downstream probe, and probing every not-yet-ready backend on every reconcile is pure noise). The signal source is the server-side /probe endpoint the controller POSTs to once per backend per rate-limit window (~30s); the reconciler reads the per-stage outcome (ingest, routing, t2) and translates it into FunctionalProbeOK:
- all stages
ok(orskipped) →FunctionalProbeOK=True/ProbeOK, no Ready change.t2is intentionallyskippedon every install today — noT2Proberis registered into the server binary yet, so Stage C reportsskippedfor every managed backend that runs the gate (the future state where aT2Proberis wired flips this took/failed).Externalbackends never reach this evaluation at all — they are wholly exempt from the functional-probe gate per the per-CR exemption noted below — so theTrue/ProbeOKoutcome is only ever published on managed backends. - stage
failed→FunctionalProbeOK=Falsewith reasonProbeIngestFailed/ProbeRoutingFailed/ProbeT2Failedand the server's stage diagnostic in.message;Readyis downgraded toFalsewith the same reason so the operator-visible Ready signal points at the broken layer. - transport / HTTP error → behavior depends on whether a prior failure exists on this backend:
- If
FunctionalProbeOKis absent ORTrue, writeFunctionalProbeOK=Unknown/ProbeErrorand leaveReadyalone. A brief server outage should not flap every backendReady=False— the snapshot poller / policy pusher use the same noise-avoidance posture, andUnknownis the operator's signal to investigate the probe wiring itself rather than the backends. - If
FunctionalProbeOKis alreadyFalse/Probe*Failed(a prior stage failure), preserve the existing condition AND keepReady=Falsewith the prior failure's reason (sticky-False). Letting an HTTP error fade a known per-stage failure toUnknown(and therefore back toReady=True) would mask a real regression every time the server happens to be transiently unreachable. The False condition is sticky until a successful probe explicitly resolves it.
- If
- rate-limited reconcile → no probe call. If the existing
FunctionalProbeOKis alreadyFalse, the Ready downgrade is re-applied so the status patch doesn't silently overwrite the prior failure with the upstream KV gate'sReady=True. The reconciler also schedules a requeue at the rate-limit window expiry so a quiet stuck backend re-probes even without external watch events.
The gate is on by default when the controller is wired with a --server-probe-url. The operator escape hatch is the annotation inferencecache.io/skip-functional-probe: "true" (alpha soft-rollout knob; annotation not spec field so it can be retired once the gate is trusted): when set, the probe call is skipped entirely and FunctionalProbeOK=True/ProbeBypassed is published — the Ready gate does not downgrade. Disabling the controller-side gate entirely is achieved by passing --server-probe-url="" to the controller binary; the CacheBackend reconciler then never calls the endpoint and clears any stale FunctionalProbeOK condition the next time it processes the CR. External ownership is always exempt — there is no managed Deployment for the gate to compose with, and the controller does not drive a cache-plane round trip for an operator-managed endpoint.
Operator note. If a backend is stuck at Ready=False/Probe*Failed, read the condition's .message — the server populates a stage-specific diagnostic (e.g. "synthesized event not in index — in-process index ingest path is broken"). For ProbeIngestFailed the cache-server's ingest path is broken; for ProbeRoutingFailed the index routing / key-derivation layer is broken; for ProbeT2Failed the configured tier-2 backend rejected the put or returned nothing on the get. FunctionalProbeOK=Unknown/ProbeError means the controller could not reach /probe at all — check that --server-probe-url is reachable from the controller pod (intra-cluster inference-cache-server:8081 by default), that the projected SA token is mounted (/var/run/secrets/inferencecache.io/controller-token/token), and that the audience-bound TokenReview accepts the controller SA.
| Field | Type | Purpose |
|---|---|---|
indexParticipation.prefixCount |
integer | Sum of distinct prefix entries currently attributed to this backend's replicas. 0 is a valid observed value (the backend is up but holds no warm prefixes yet); always serialized. |
indexParticipation.lastEventAt |
time | Most recent KV-event timestamp observed for any of this backend's replicas. Unset until the first event arrives; readiness gates must treat the absent value as "not yet observed" rather than epoch. |
indexParticipation.hitRate |
string | Prefix-count-weighted cache hit rate across this backend's replicas, formatted as a decimal in [0,1]. Always unset today — a missing value MUST NOT be interpreted as 0. The per-replica snapshot now carries a stats-reported presence bit, but the per-backend view aggregates many replicas onto one backend with no defined backend-level hit-rate reduction (mean? token-weighted? over which replicas?), so backend hit-rate aggregation is deliberately deferred to a follow-up; that presence bit is consumed only by the cluster-aggregate CacheIndex.status. Do not expect this field to begin populating from the presence-bit change alone. |
indexParticipation.t2HitRate |
string | Query-weighted reload hit-rate of the tier-2 (external offload, e.g. LMCache) cache across this backend's replicas, as a decimal in [0,1]. Sourced from the engines' vllm:external_prefix_cache_{hits,queries}_total. Presence is load-bearing: unset means tier-2 has not been exercised (no external lookups across any replica) — distinct from "0", which means the tier WAS queried but served zero reloads, i.e. a silently-degraded offload tier (store/connection failure, under-sized remote server, or scheduler/worker hash mismatch). A healthy reusing workload reads well above 0. |
The poller attributes each /snapshot.replicas[] entry to a single owning CacheBackend by resolving the engine pod it came from. The subscriber sidecar runs inside the engine pod and reports replica_id = <pod-name>, tenant_id = <pod-namespace>. For each replica the poller:
- Looks up the engine pod by
(tenant, replicaID). - If the pod carries the webhook's
inferencecache.io/injected-byannotation (stamped as<namespace>/<name>), resolves the owning CacheBackend directly. This is the authoritative wiring signal — the engine container was wired to exactly that backend's endpoint. - Otherwise, iterates that namespace's CacheBackends sorted by
metadata.nameand picks the first whosespec.engineSelector.matchLabelsis non-empty and is a subset of the pod's labels. This mirrors the pod webhook's first-match rule for pods that bypassed the webhook (manual sidecar attachment, opt-out).
Only ONE CacheBackend ever claims a given replica — overlapping selectors must agree on which backend owns the pod, otherwise status would disagree with what the engine was actually wired to. A CacheBackend without an EngineSelector (or with empty MatchLabels) is excluded from the selector fallback — otherwise a misconfigured backend would silently claim every replica in its namespace by vacuous truth — but a pod can still be attributed to it via the injected-by annotation. A replica whose pod can no longer be found (drained between events and now) is skipped; its data still appears in the cluster-wide CacheIndex. A failing scrape preserves existing state (soft-state); a successful scrape that finds no matching replicas resets prefixCount to 0 so stale positive values do not survive a drain.
- Lookup paths fail open by default.
spec.integration.failOpendefaults totrueand the engine adapter MUST fall back to local prefill on unreachability of a remote/shared cache tier — that tier is an optimization, never a serving dependency. One pair-specific exception applies to the co-scheduled component of the SGLang MP wire:(sglang, LMCache)has no cacheless engine path while--enable-lmcacheis on, so its in-pod MP worker is a serving prerequisite (fail-open is still honored at the tier that can be unavailable — the shared Redis L2, which degrades to L1-only). See theintegration.failOpenrow above and the fail-open semantics insglang-lmcache-mp-mode.md. Operators may opt into fail-closed serving by settingfailOpen: false, which is loud and visible: the controller emits a WarningFailClosedEnabledEvent on theCacheBackendto make it explicit that the cache has been promoted to a serving dependency. - The controller emits Events on the
CacheBackendonly on meaningful state changes, never on steady-state reconciles. Condition-transition-keyed Events:BackendDegraded(Warning) on enteringConditions[Degraded]=Truewith reasonReplicasUnavailable(the KV-event-gateNoKVEventsObservedflavor is suppressed — it carries its own event),BackendRecovered(Normal) on the transition back toReady=True(similarly suppressed when recovering fromNoKVEventsObserved, which carries its ownKVEventsObservedevent); theFailClosedEnabled/FailOpenRestoredpair above; the KV-event readiness gate'sAwaitingFirstKVEvent(Normal),KVEventsObserved(Normal), andNoKVEventsObserved(Warning);EngineSelectorUnmatched(Normal) when a configured selector first observes zero matching pods while engine pods are expected, transitions from matched to zero, or gains the diagnostic message during an upgrade from an older zero-count status. One advisory Event is recorded on theCacheBackendbut triggered by engine-pod state rather than a CacheBackend condition transition:InjectedEngineCrashLooping(Warning) is emitted once when an injected engine pod's engine container is first observed in CrashLoopBackOff after connector injection — commonly a connector incompatibility (esp. a hybrid-attention model), surfaced asEngineCompatibility=False/InjectedEngineCrashLooping, but a crash-loop can also be a bad image/command/secret/OOM, so the cause is verified via the engine logs, not asserted by the Event. The controller does not watch engine pod status — it detects this on the nextCacheBackendreconcile that lists the pods, so the Event reflects observation time, not the instant the container entered CrashLoopBackOff; a transient pod-list failure preserves the prior condition rather than re-firing it. - A
Normal InjectedByCacheBackendEvent is emitted on engine pods the mutating webhook stamps with bothinferencecache.io/injected-byANDinferencecache.io/injected-by-uid, where the UID annotation matches the live CacheBackend'smetadata.uidat reconcile time. The controller deliberately skips emission when (a) the named CR cannot be looked up (NotFound), (b) the UID annotation is absent (failurePolicy=Ignore forgery shape), or (c) the UID does not match the live CR (forgery or CR was recreated under the same name). Non-NotFound lookup errors surface as reconcile errors so controller-runtime retries with backoff. A pod explicitly opted out with a truthyinferencecache.io/skip-injectis instead stamped withinferencecache.io/inject-skipped: skip-inject-annotation; the same post-create controller emits aNormal SkippedByOperatorEvent only when both the truthy opt-out annotation and the webhook's skipped marker are present. The Events are recorded by a Pod-watching controller, not by the webhook itself: at mutating-admission time the apiserver hasn't assignedmetadata.uidto the pod yet, so an event recorded from the webhook would carryinvolvedObject.uid=""and be invisible to describe (which filters events by UID). Routing the emission through a post-create controller is what guarantees the event reaches the user-visible surface. There is noNoMatchingCacheBackendEvent; the no-match signals arestatus.matchedEnginePods == 0,status.engineSelectorMessage, andEngineSelectorUnmatchedon the CacheBackend. - Optional nested specs are pointer fields in Go so omitted objects stay absent in JSON and server-side apply does not claim empty nested objects.
spec.integrationis the deliberate exception — the defaulting webhook materialises it on admission, derivesenginefrom canonicalspec.runtime(or uses legacyvllm), and gives the nested schema-level defaults a parent object to apply to. The apiserver then applies the+kubebuilder:default=markers onmode(Offload),role(ReadWrite),failOpen(true), andfirstEventTimeout(5m) before persisting the CR. Operators reading the persisted CR therefore see the effective compatibility fields explicitly. TheIntegrationFailOpenreader helper still exists (nil spec or nil field ⇒true) as defence-in-depth for callers that bypass admission. Other optional nested specs (spec.autoscaling,spec.template,spec.engineSelector) are NOT materialised — omitted means absent. Webhook-stamped and apiserver-stamped fields are owned by their respective field managers, not the operator's SSA apply, so SSA semantics for operator-set fields are unaffected.
The controller serves two webhooks for CacheBackend, both registered as failurePolicy: Fail with sideEffects: None on CREATE and UPDATE. Webhook serving requires cert-manager (see README "Cluster Prerequisites").
Most Phase-1 literal defaults ride on +kubebuilder:default= markers stamped by the apiserver before the webhook runs (spec.type=LMCache, spec.deploymentKind=Deployment, spec.replicas=1, spec.integration.mode=Offload, spec.integration.role=ReadWrite, spec.integration.failOpen=true, spec.observation.firstEventTimeout=5m). The webhook handles context-dependent defaults; operator-set values are never clobbered.
| Field | Default | Layer |
|---|---|---|
spec.type, spec.deploymentKind, spec.replicas, spec.integration.{mode,role,failOpen}, spec.observation.firstEventTimeout |
per-field literals (see field godoc) | +kubebuilder:default= markers — apiserver |
spec.observation.firstEventTimeout (when spec.observation is omitted entirely) |
5m |
webhook materialises spec.observation so the nested marker has a parent object to apply to |
spec.autoscaling.minReplicas (FIRST APPLY ONLY, when spec.autoscaling != nil and spec.autoscaling.minReplicas == nil) |
= spec.replicas (post-marker-default; skipped when spec.replicas is 0 to avoid violating the schema's Minimum=1) |
webhook |
The spec.autoscaling.minReplicas default is first-apply only. The defaulter refuses to overwrite a non-nil value, AND once stamped the field is owned by the apiserver field manager, so a subsequent edit to spec.replicas does NOT recompute or move minReplicas. This matches the standard Kubernetes HPA convention that scaling intent flows through HPA fields once an HPA owns the workload — to widen or narrow the autoscaling band post-apply, edit spec.autoscaling.minReplicas directly. (The replicas=0 + autoscaling + nil minReplicas case is rejected at admission rather than defaulted; see the validator table below.)
Rejects structurally-broken specs that the reconciler cannot do anything useful with, with field-scoped error messages. Multiple violations on a single spec are aggregated into one Invalid status so kubectl prints them together.
| Rule | Rejects |
|---|---|
| Cache hierarchy must be internally consistent | A provider-specific typed block does not match remoteStorage.provider/ownership, lmCache is used with a non-LMCache type, or host-only configuration requests workload autoscaling. |
| External remote storage requires an endpoint | remoteStorage.ownership=External without remoteStorage.endpoint; managed ownership rejects a user-supplied endpoint. |
| Engine wire must accept the provider binding | Every (runtime, type) adapter must explicitly implement the remote-binding contract, and admission rejects a binding it does not accept (lm, resp, mooncakestore, or host-only). Native SGLang HiCache accepts only the nil host-only binding; attaching any remoteStorage is rejected. |
| Provider resources must be valid | Typed provider resource blocks are checked for request/limit relationships, claims, quantities, resource names, extended resources, and hugepage alignment, with errors reported at the selected provider path. |
| Endpoint ownership is explicit | spec.remoteStorage.endpoint is required for External ownership and rejected for Managed ownership. A managed endpoint always comes from the live Service the controller provisions, so a user-supplied value would be misleading. Whitespace-only values are treated as empty. |
| Cross-namespace endpoint requires opt-in | spec.remoteStorage.endpoint resolves to a Service in a namespace other than the CacheBackend's, while spec.allowCrossNamespace is false. Crossing the namespace is a tenancy boundary the operator must acknowledge. Bare hostnames, IPs, and unqualified names pass through because no namespace can be inferred. |
spec.replicas=0 + autoscaling requires explicit minReplicas |
spec.replicas=0 with spec.autoscaling != nil and spec.autoscaling.minReplicas == nil. The defaulter declines to compute minReplicas from a 0 replicas value (it would violate the schema's Minimum=1), so without this rule the apiserver accepts the CR and the reconciler's HPA fallback silently picks 1 — overriding the operator's "scale to zero" intent with no notification. The rejection tells the operator to either set minReplicas explicitly or remove spec.autoscaling to scale to zero unconditionally. |
spec.integration.engineOverrides cannot touch reserved args/env |
An entry in engineOverrides.args / engineOverrides.suppressArgs matches a leading flag token the adapter declares as ReservedArgs(), or an entry in engineOverrides.env / engineOverrides.suppressEnv matches a name in ReservedEnv(). The rejection names both the offending flag/env and the adapter so the operator can fix the spec rather than wait for the engine to crash. The reserved set is per-adapter (the vLLM+LMCache adapter reserves --kv-transfer-config, VLLM_USE_V1, LMCACHE_REMOTE_URL, INFERENCECACHE_FAIL_OPEN, PYTHONHASHSEED). |
| Provider resource limits and requests must agree | Under spec.remoteStorage.<provider>.resources, overcommittable resource limits must be ≥ requests; hugepages and extended resources must use equal request/limit values. |
| Requests-only is rejected for non-overcommittable resources | A hugepage or vendor-prefixed extended resource is present in a provider resources.requests map without a matching limit. |
Provider resources.claims is not supported |
A selected provider resource block contains Dynamic Resource Allocation claim names, but the renderer does not yet create matching pod-level spec.resourceClaims. |
| Extended-resource quantities must be integers | A selected provider resource block gives a vendor-prefixed extended resource a fractional value. |
| Hugepage quantities must align to the page size | A selected provider resource block contains a positive hugepages-<size> quantity that is not a whole multiple of its page size. |
| Provider resource quantities must be non-negative | A selected provider resources.requests or resources.limits entry is negative. |
| Provider resource names must be valid | A selected provider resource key is not a valid standard, hugepage, or vendor-prefixed container resource name. |
| Runtime/cache pair must be supported by an installed adapter | The (runtime, engine-cache type) pair has no registered runtime adapter, so the reconciler cannot observe engine compatibility and the pod webhook would fail open without injecting engine config. The shipping pairs are VLLM/LMCache, SGLang/LMCache, and SGLang/SGLangHiCache; remote provider selection is validated independently through remoteStorage. The registry's SupportedPairs list is included in the field-scoped rejection. |
Events-only requires spec.type=LMCache |
spec.integration.mode=EventsOnly with any spec.type other than LMCache (the default). Events-only wires no KV connector, so declaring an offload-oriented cache type is contradictory. LMCache supplies the kvevent-subscriber that the routing tier needs. See Events-only mode. |
Events-only forbids spec.autoscaling |
spec.integration.mode=EventsOnly with spec.autoscaling set. An events-only backend provisions no server workload, so there is nothing to autoscale. Field-scoped to spec.autoscaling. |
The structural rules are an ordered, pluggable list (CacheBackendValidator.Rules); the runtime/backend compatibility check runs separately because it needs to consult the shared adapterruntime.Registry rather than just the spec.
ValidateUpdate only rejects violations the update introduces: errors that already existed on the previous object are filtered out so an unrelated edit (a label tweak, an annotation) on a CR admitted under a laxer rule set is not suddenly un-updatable. A kubectl edit that flips a previously-valid field into an invalid one is still rejected, because the violation is then new to the diff. Errors are compared by (Type, Field, BadValue, Detail), so an operator changing one bad endpoint to a different bad endpoint on the same field counts as a fresh violation — the rule still bites when the operator actively edits the bad field.
Inference-cache has not been formally deployed, so this version does not ship a resource conversion or compatibility reader. Manifests must use spec.runtime, typed spec.lmCache, spec.remoteStorage.<provider>, spec.observation, and provider-owned resources. The removed spec.integration.engine, spec.integration.firstEventTimeout, spec.backendConfig, and top-level spec.resources fields are not part of the served CRD schema.
spec.integration.engineOverrides lets the operator amend the non-reserved args/env the pod-mutating webhook injects into the engine container — without forking an adapter. It is the user-facing seam that today's CPU-vLLM-with-LMCache use case and the SGLang+LMCache adapter reach to tune adapter-injected knobs (chunk size, max model length, serdes) that the canonical injection would otherwise hard-code. The reserved set (per locked decision #5/#6 below) makes this surface unsuitable for turning the integration off: operators who need to skip injection entirely on a pod should use the inferencecache.io/skip-inject annotation instead.
Shape, in corev1 vocabulary:
| Field | Type | Behavior |
|---|---|---|
args |
[]string |
Args added to the engine container, scoped to adapter-owned flags. An entry whose leading flag token matches an adapter-owned canonical arg replaces it; an entry whose token is in neither the adapter-owned set nor the user pod-template is appended; an entry colliding with a user-template flag the adapter did not touch is a silent no-op. Order preserved. |
suppressArgs |
[]string |
Leading flag names the adapter MUST NOT inject. Restricted to the adapter-owned set: a suppress entry that names a user-template flag the adapter did not inject is a silent no-op. |
env |
[]corev1.EnvVar |
Env upserted by Name, scoped to adapter-owned canonical entries. An override of an adapter-owned name wins; a new name (not on the user template) is appended; a name colliding with a user-owned env the adapter did not touch is a silent no-op. |
suppressEnv |
[]string |
Env var Names the adapter MUST NOT inject. Restricted to adapter-owned entries; user-owned env is protected. |
The "adapter-owned" set is derived by the webhook at admission time by diffing the engine container's args/env immediately before and after InjectEngineConfig runs. A flag/env is adapter-owned if the adapter added it OR modified an existing value. User pod-template entries the adapter does not touch are protected from CR-driven mutation — the CR can amend the engine integration, but not silently rewrite the engine pod owner's own template.
No command override (the entrypoint stays user-owned). No resources override on the engine container here — engine-pod resources are user-owned via the engine's own pod template, not this CR. Managed provider resources are configured under spec.remoteStorage.<provider>.resources.
The CRD field default is byte-identical to the prior behavior: a CacheBackend with no engineOverrides block renders the same injected patch as before.
Each KVCacheRuntimeAdapter declares two methods:
ReservedArgs() []string— leading flag tokens the user MUST NOT override or suppress.ReservedEnv() []string— env var names the user MUST NOT override or suppress.
The validating webhook selects the adapter from spec.runtime, then iterates
its reserved lists and hard-rejects any engineOverrides.{args,suppressArgs} entry
that overlaps ReservedArgs() and any engineOverrides.{env,suppressEnv}
entry that overlaps ReservedEnv(). The rejection names the offending
flag/env and the adapter. Warning-only would let a user silently un-wire the
integration and discover it via a crashed engine; the hard-reject keeps the
breadcrumb at admission time.
The vLLM+LMCache adapter (internal/adapters/builtin/runtime/vllm_lmcache.go) reserves the args/env the integration cannot function without:
ReservedArgs():--kv-transfer-config(the LMCache connector wiring).ReservedEnv():VLLM_USE_V1(selects the engine codepath the connector targets),LMCACHE_REMOTE_URL(the resolved cache endpoint),INFERENCECACHE_FAIL_OPEN(mirror ofspec.integration.failOpen— overriding it would silently desync the pod from the CR contract),PYTHONHASHSEED(pins the deterministicNONE_HASHso LMCache reload matches under TP>1 — overriding or suppressing it silently 0-hits reload).
The same reserved set applies when the canonical vLLM/LMCache engine cache has an External LMCacheServer binding or a Mooncake binding: the selected runtime adapter still runs the LMCache connector and varies only the structured binding's protocol and endpoint. Admission therefore rejects an override that would remove connector wiring regardless of provider ownership. See Mooncake provider configuration.
The SGLang+LMCache adapter (internal/adapters/builtin/runtime) reserves a different set, because SGLang's engine-side wire is the LMCache MP wire, not the lm:// one (see SGLang engine support): ReservedArgs() = --enable-lmcache, --lmcache-config-file; ReservedEnv() = LMCACHE_USE_EXPERIMENTAL, INFERENCECACHE_FAIL_OPEN. Suppressing --lmcache-config-file un-wires MP mode (the engine aborts at startup without it), hence its reservation. In MP mode the lm:// LMCACHE_REMOTE_URL is neither injected nor reserved, and VLLM_USE_V1 / PYTHONHASHSEED are never injected for SGLang. Reservation is per-adapter precisely so each engine guards only the flags/env its own integration cannot function without.
LMCACHE_CHUNK_SIZE, LMCACHE_REMOTE_SERDE, LMCACHE_LOCAL_CPU, LMCACHE_MAX_LOCAL_CPU_SIZE are deliberately NOT reserved — they are perf/mode tunables the operator may legitimately want to change. Canonical chunk size, serializer, and host-memory capacity use spec.lmCache; engineOverrides.env remains the engine-agnostic seam for explicit environment-level tuning.
Two shapes were on the table:
- A — typed K8s vocabulary (
[]stringargs,[]corev1.EnvVarenv, plus suppression). Chosen. - B — free-form magic keys (
cpuMode: "true",gpuLimit: "0",extraArgs: "..."). Rejected.
A is more general: Mooncake remote bindings, the SGLang adapter, and further engine/backend pairs plug in with no per-adapter free-form schema churn. It keeps the CRD disciplined. B is faster to ship but bakes engine-specific knobs into the CRD, which is the trap an "engine-agnostic backend" surface is meant to avoid.
A user can still set non-reserved values that break the engine in subtle ways the validator can't catch — e.g. --max-model-len 999999999 OOMing the engine, or env that subtly changes vLLM behavior. Mitigations shipped with this surface:
- Field godoc carries a "known-fragile" callout.
ReservedEnv()mirrorsReservedArgs()for the worst offenders, so the canonical wiring can't be silently un-wired.- Default samples in
config/samples/exercise the no-override path so a future drift in the adapter's canonical injection breaks them loudly.
A separate mutating admission webhook on corev1/v1.Pod (name: mpod.inferencecache.io) auto-wires user-supplied inference engine pods to the matching CacheBackend across all three lifecycle shapes: controller-managed server backends, operator-managed External endpoints, and engine-local backends such as SGLang HiCache. Operators do not have to hand-edit the adapter-specific args, env, sidecars, volumes, or mounts onto their pod templates. The handler lives in internal/webhook/pod and runs on every Pod CREATE.
| Aspect | Behavior |
|---|---|
| Selection | Lists CacheBackends in the pod's namespace via the manager's APIReader (uncached live client; an informer-cache miss on a freshly-Ready backend would leave the pod permanently unwired since pod CREATE is a one-shot), then matches pod.Labels against each Spec.EngineSelector.MatchLabels. The first matching CacheBackend wins; one with a nil or empty EngineSelector is skipped (a "match-everything" selector would silently claim every pod in the namespace). |
| Injection | Resolves the runtime adapter via runtime.Registry.Select(runtimeID, cache), resolves spec.remoteStorage independently, and constructs a structured provider Binding{Protocol, Endpoint}. Managed ownership uses status.endpoint from the live Service; External ownership uses the trimmed, provider-validated spec.remoteStorage.endpoint with no fallback to stale status; omitted remoteStorage produces a nil host-only binding. SupportsBinding is part of the required runtime adapter interface, and the webhook passes the binding directly to adapter.InjectEngineConfig, so the adapter selects the LMCache, RESP, or Mooncake engine wire from the binding protocol instead of inferring storage from spec.type. A non-nil binding with a missing endpoint fails open. Events-only skips engine injection because it wires no KV connector and appends only the kvevent-subscriber sidecar. Adapters preserve existing user args/env and make repeat injection idempotent. |
| Annotations | Stamps TWO annotations on every successfully mutated pod: inferencecache.io/injected-by: <namespace>/<name> (operator-readable identity, shows in kubectl describe pod) AND inferencecache.io/injected-by-uid: <cache.UID> (the matched CR's metadata.uid). Successful injection also clears any stale inferencecache.io/inject-skipped marker. Reads inferencecache.io/skip-inject: <truthy> as an opt-out: the webhook returns Allowed, skips engine wiring, clears any stale injected-by/injected-by-uid pair, and stamps inferencecache.io/inject-skipped: skip-inject-annotation so explicit operator opt-out is distinguishable from selector drift. On all other fail-open returns after the pod is decoded (list/no match/missing endpoint/adapter errors), the webhook strips stale injected-by/injected-by-uid and inject-skipped annotations so a user cannot trick the events controller by pre-stamping a pod template. Decode failures fail open before a Pod exists to patch, so stale annotations cannot be cleared on that path. |
| Events | The webhook itself does NOT record events (the apiserver assigns metadata.uid after mutating admission, so a webhook-recorded event would carry involvedObject.uid="" and be invisible to kubectl describe pod). Instead, the pod-watching engine-pod-events controller reads the persisted decision annotations after CREATE. For injected pods, it validates inferencecache.io/injected-by-uid against the live CR's metadata.uid and records a Normal InjectedByCacheBackend event on the now-persisted pod. For explicitly skipped pods carrying both a truthy inferencecache.io/skip-inject and inferencecache.io/inject-skipped: skip-inject-annotation, it records a Normal SkippedByOperator event on that pod. The skip marker is not authenticated, and skipInjection treats a pre-existing correct marker as already converged; SkippedByOperator therefore means the persisted pod carries the explicit opt-out plus skipped marker, not proof that the webhook authored the marker. The UID match REDUCES — but does NOT eliminate — the failurePolicy=Ignore forgery surface for injected pods: a casual copy-paste of an injected pod's annotations into a fresh template won't match the live CR's UID, but metadata.uid is not secret, so a pod creator with get RBAC on CacheBackends can read it and stamp the pair correctly. The injected Event signals "the webhook claims this pod was injected and the claim is consistent with the live CR," not "the webhook was cryptographically authenticated." The controller skips the injected event when the CR is missing, the UID annotation is absent, or the UID does not match — see the controller godoc for the full skip table. controller-runtime's EventBroadcaster aggregates duplicates on the apiserver side, so a re-enqueue across controller restarts upserts the existing event rather than spamming. |
| Idempotency | The handler calls the adapter unconditionally on every admission and trusts the adapter to converge the full injected contract. For LMCache this is env plus the engine-specific required surface — --kv-transfer-config for vLLM; for SGLang --enable-lmcache + --lmcache-config-file plus the MP-worker native sidecar and the shared config / /dev/shm volumes + mounts. Its merge primitives (upsertEnv / upsertArgPair / upsertFlag, and for SGLang adoptContainer / adoptVolume / upsertMountByName) converge on the desired value rather than appending a duplicate. The SGLang adopt* pair additionally distinguishes the adapter's own prior injection (converge) from an operator's object squatting a reserved name (reject → fail-open admit) — see Names the MP wire reserves. Native HiCache validates all reserved arguments against the original pod before mutation, preserves one matching or well-formed operator-supplied value, appends each missing canonical argument once, and rejects conflicts, malformed values, or duplicates without partially changing the pod. Re-admission of a fully-injected pod therefore produces an empty JSON-patch set. Trusting the adapter rather than a handler-side env-presence shortcut avoids the trap where a partially-injected pod is admitted permanently missing the rest of the contract. |
| Fail-open | Every error path (decode failure, list error, no matching backend, missing status.endpoint, no registered adapter, adapter rejection, re-encode failure) returns admission.Allowed(...) with a reason — webhook errors MUST NOT block engine admission. MutatingWebhookConfiguration.failurePolicy is also pinned to Ignore as a belt-and-suspenders second layer. |
| Verbs | CREATE only. UPDATE re-admissions to a running pod don't re-inject (and the engine container can't pick up env changes without a restart anyway); UPDATEs to engine pods are rare in this fleet. |